use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

use super::database::Database;
use super::github_enrichment;

/// A catalog source that can be synced to discover available AppImages.
#[derive(Debug, Clone)]
pub struct CatalogSource {
    pub id: Option<i64>,
    pub name: String,
    pub url: String,
    pub source_type: CatalogType,
    pub enabled: bool,
    pub last_synced: Option<String>,
    pub app_count: i32,
}

#[derive(Debug, Clone, PartialEq)]
pub enum CatalogType {
    AppImageHub,
    OcsAppImageHub,
    GitHubSearch,
    Custom,
}

impl CatalogType {
    pub fn as_str(&self) -> &str {
        match self {
            Self::AppImageHub => "appimage-hub",
            Self::OcsAppImageHub => "ocs-appimagehub",
            Self::GitHubSearch => "github-search",
            Self::Custom => "custom",
        }
    }

    /// Human-readable label for display in the UI.
    pub fn label(&self) -> &str {
        match self {
            Self::AppImageHub => "Community Feed",
            Self::OcsAppImageHub => "AppImageHub Catalog",
            Self::GitHubSearch => "GitHub Search",
            Self::Custom => "Custom Source",
        }
    }

    pub fn from_str(s: &str) -> Self {
        match s {
            "appimage-hub" => Self::AppImageHub,
            "ocs-appimagehub" => Self::OcsAppImageHub,
            "github-search" => Self::GitHubSearch,
            _ => Self::Custom,
        }
    }
}

impl std::fmt::Display for CatalogType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// An app entry from a catalog source.
#[derive(Debug, Clone)]
pub struct CatalogApp {
    pub name: String,
    pub description: Option<String>,
    pub categories: Vec<String>,
    pub latest_version: Option<String>,
    pub download_url: String,
    pub icon_url: Option<String>,
    pub homepage: Option<String>,
    pub file_size: Option<u64>,
    pub architecture: Option<String>,
    pub screenshots: Vec<String>,
    pub license: Option<String>,
    /// GitHub link URL from the feed (e.g. "https://github.com/user/repo")
    pub github_link: Option<String>,
}

/// Default AppImageHub registry URL.
const APPIMAGEHUB_API_URL: &str = "https://appimage.github.io/feed.json";

/// OCS API base URL for AppImageHub.com.
const OCS_API_URL: &str = "https://api.appimagehub.com/ocs/v1/content/data";
const OCS_PAGE_SIZE: u32 = 100;

// --- OCS API response types ---

/// Deserialize a JSON value that may be a number, a numeric string, or an empty string.
/// The OCS API is loosely typed and sometimes returns "" instead of null for numeric fields.
fn deserialize_lenient_i64<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::Deserialize;
    let v = serde_json::Value::deserialize(deserializer)?;
    match &v {
        serde_json::Value::Number(n) => Ok(n.as_i64()),
        serde_json::Value::String(s) if s.is_empty() => Ok(None),
        serde_json::Value::String(s) => Ok(s.parse().ok()),
        serde_json::Value::Null => Ok(None),
        _ => Ok(None),
    }
}

fn deserialize_lenient_i32<'de, D>(deserializer: D) -> Result<Option<i32>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::Deserialize;
    let v = serde_json::Value::deserialize(deserializer)?;
    match &v {
        serde_json::Value::Number(n) => Ok(n.as_i64().map(|n| n as i32)),
        serde_json::Value::String(s) if s.is_empty() => Ok(None),
        serde_json::Value::String(s) => Ok(s.parse().ok()),
        serde_json::Value::Null => Ok(None),
        _ => Ok(None),
    }
}

#[derive(Debug, serde::Deserialize)]
struct OcsResponse {
    data: Vec<OcsItem>,
}

#[derive(Debug, serde::Deserialize)]
struct OcsItem {
    #[serde(deserialize_with = "deserialize_lenient_i64", default)]
    id: Option<i64>,
    #[serde(default)]
    name: String,
    description: Option<String>,
    summary: Option<String>,
    downloads: Option<String>,
    #[serde(deserialize_with = "deserialize_lenient_i32", default)]
    score: Option<i32>,
    typename: Option<String>,
    personid: Option<String>,
    version: Option<String>,
    tags: Option<String>,
    changed: Option<String>,
    created: Option<String>,
    previewpic1: Option<String>,
    previewpic2: Option<String>,
    previewpic3: Option<String>,
    previewpic4: Option<String>,
    previewpic5: Option<String>,
    previewpic6: Option<String>,
    smallpreviewpic1: Option<String>,
    downloadlink1: Option<String>,
    downloadname1: Option<String>,
    #[serde(deserialize_with = "deserialize_lenient_i64", default)]
    downloadsize1: Option<i64>,
    download_package_arch1: Option<String>,
    download_package_type1: Option<String>,
    downloadmd5sum1: Option<String>,
    detailpage: Option<String>,
    #[serde(deserialize_with = "deserialize_lenient_i64", default)]
    comments: Option<i64>,
}

/// Extra OCS-specific metadata not in the in-memory CatalogApp.
struct OcsExtra {
    ocs_id: i64,
    ocs_downloads: Option<i64>,
    ocs_score: Option<i64>,
    ocs_typename: Option<String>,
    ocs_personid: Option<String>,
    ocs_description: Option<String>,
    ocs_summary: Option<String>,
    ocs_version: Option<String>,
    ocs_tags: Option<String>,
    ocs_changed: Option<String>,
    ocs_preview_url: Option<String>,
    ocs_detailpage: Option<String>,
    ocs_created: Option<String>,
    ocs_downloadname: Option<String>,
    ocs_downloadsize: Option<i64>,
    ocs_arch: Option<String>,
    ocs_md5sum: Option<String>,
    ocs_comments: Option<i64>,
}

/// OCS download resolution response.
#[derive(Debug, serde::Deserialize)]
struct OcsDownloadResponse {
    data: Vec<OcsDownloadItem>,
}

#[derive(Debug, serde::Deserialize)]
struct OcsDownloadItem {
    #[serde(rename = "downloadlink")]
    download_link: Option<String>,
}

/// A downloadable file from an OCS content item.
/// Each OCS item can have multiple download files (different versions).
#[derive(Debug, Clone)]
pub struct OcsDownloadFile {
    pub slot: u32,
    pub ocs_id: i64,
    pub filename: String,
    pub version: String,
    pub size_kb: Option<i64>,
    pub arch: Option<String>,
    pub pkg_type: Option<String>,
}

/// Sync a catalog source - fetch the index and store entries in the database.
/// Progress updates sent during catalog sync.
#[derive(Debug, Clone)]
pub enum SyncProgress {
    /// Starting sync for a named source.
    SourceStarted { name: String, source_index: u32, source_count: u32 },
    /// Fetching the feed from the remote source.
    FetchingFeed,
    /// Feed fetched, total number of apps found.
    FeedFetched { total: u32 },
    /// Caching icon for an app.
    CachingIcon { current: u32, total: u32, app_name: String },
    /// Saving apps to the database.
    SavingApps { current: u32, total: u32 },
    /// Single source sync complete.
    Done { total: u32 },
    /// All sources finished syncing.
    AllDone,
}

#[allow(dead_code)]
pub fn sync_catalog(db: &Database, source: &CatalogSource) -> Result<u32, CatalogError> {
    sync_catalog_with_progress(db, source, &|_| {})
}

pub fn sync_catalog_with_progress(
    db: &Database,
    source: &CatalogSource,
    on_progress: &dyn Fn(SyncProgress),
) -> Result<u32, CatalogError> {
    on_progress(SyncProgress::FetchingFeed);

    match source.source_type {
        CatalogType::OcsAppImageHub => {
            return sync_ocs_catalog(db, source, on_progress);
        }
        _ => {}
    }

    let apps = match source.source_type {
        CatalogType::AppImageHub => fetch_appimage_hub()?,
        CatalogType::Custom => fetch_custom_catalog(&source.url)?,
        CatalogType::GitHubSearch => {
            log::warn!("GitHub catalog search not yet implemented");
            Vec::new()
        }
        CatalogType::OcsAppImageHub => unreachable!(),
    };

    let total = apps.len() as u32;
    on_progress(SyncProgress::FeedFetched { total });

    // Cache icons with progress reporting
    let icon_count = cache_catalog_icons_with_progress(&apps, on_progress);
    log::info!("Cached {} catalog icons", icon_count);

    let source_id = source.id.ok_or(CatalogError::NoSourceId)?;

    // Clean up old duplicates: delete secondary source entries that now have OCS counterparts
    match db.delete_secondary_duplicates(source_id) {
        Ok(n) if n > 0 => log::info!("Removed {} secondary duplicates with OCS counterparts", n),
        _ => {}
    }

    // Build set of OCS app names for dedup (skip apps already in OCS source)
    let ocs_names = get_ocs_source_names(db);

    let mut count = 0u32;

    for app in &apps {
        // Deduplicate: skip if this app name exists in the OCS source
        if ocs_names.contains(&app.name.to_lowercase()) {
            continue;
        }

        count += 1;
        on_progress(SyncProgress::SavingApps { current: count, total });

        let screenshots_str = if app.screenshots.is_empty() {
            None
        } else {
            Some(app.screenshots.join(";"))
        };

        db.insert_catalog_app(
            source_id,
            &app.name,
            app.description.as_deref(),
            Some(&app.categories.join(", ")),
            app.latest_version.as_deref(),
            &app.download_url,
            app.icon_url.as_deref(),
            app.homepage.as_deref(),
            app.file_size.map(|s| s as i64),
            app.architecture.as_deref(),
            screenshots_str.as_deref(),
            app.license.as_deref(),
        ).ok();

        // Extract and store GitHub owner/repo
        if let Some((owner, repo)) = github_enrichment::extract_github_repo(
            app.github_link.as_deref().or(app.homepage.as_deref()),
            &app.download_url,
        ) {
            // Get the app ID we just inserted/updated
            if let Ok(Some(db_app)) = db.get_catalog_app_by_source_and_name(source_id, &app.name) {
                db.update_catalog_app_github_repo(db_app, &owner, &repo).ok();
            }
        }
    }

    db.update_catalog_source_sync(source_id, count as i32).ok();

    on_progress(SyncProgress::Done { total: count });
    Ok(count)
}

/// Download an AppImage from the catalog to a local directory.
/// If `ocs_id` is provided, resolves a fresh download URL from the OCS API
/// (since OCS download links use JWT tokens that expire).
#[allow(dead_code)]
pub fn install_from_catalog(app: &CatalogApp, install_dir: &Path) -> Result<PathBuf, CatalogError> {
    install_from_catalog_with_ocs(app, install_dir, None, 1)
}

/// Download an AppImage, optionally resolving a fresh OCS download URL.
/// `ocs_id` - the OCS content ID; `ocs_slot` - the file slot number (1-based, default 1).
pub fn install_from_catalog_with_ocs(
    app: &CatalogApp,
    install_dir: &Path,
    ocs_id: Option<i64>,
    ocs_slot: u32,
) -> Result<PathBuf, CatalogError> {
    fs::create_dir_all(install_dir).map_err(|e| CatalogError::Io(e.to_string()))?;

    // For OCS apps, resolve a fresh download URL (JWT links expire)
    let download_url = if let Some(id) = ocs_id {
        match resolve_ocs_download(id, ocs_slot) {
            Ok(url) => url,
            Err(e) => {
                log::warn!("Failed to resolve OCS download, falling back to stored URL: {}", e);
                app.download_url.clone()
            }
        }
    } else {
        app.download_url.clone()
    };

    // Derive filename from URL (strip query params)
    let url_path = download_url.split('?').next().unwrap_or(&download_url);
    let filename = url_path
        .rsplit('/')
        .next()
        .unwrap_or("downloaded.AppImage");

    // If filename doesn't look like an AppImage, use the app name
    let filename = if filename.contains(".AppImage") || filename.ends_with(".appimage") {
        filename.to_string()
    } else {
        format!("{}.AppImage", sanitize_filename(&app.name))
    };

    let dest = install_dir.join(&filename);

    log::info!("Downloading {} to {}", download_url, dest.display());

    let response = ureq::get(&download_url)
        .call()
        .map_err(|e| CatalogError::Network(e.to_string()))?;

    let mut file = fs::File::create(&dest)
        .map_err(|e| CatalogError::Io(e.to_string()))?;

    let mut reader = response.into_body().into_reader();
    let mut buf = [0u8; 65536];
    loop {
        let n = reader.read(&mut buf)
            .map_err(|e| CatalogError::Network(e.to_string()))?;
        if n == 0 { break; }
        file.write_all(&buf[..n])
            .map_err(|e| CatalogError::Io(e.to_string()))?;
    }

    // Set executable permission
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let perms = fs::Permissions::from_mode(0o755);
        fs::set_permissions(&dest, perms)
            .map_err(|e| CatalogError::Io(e.to_string()))?;
    }

    Ok(dest)
}

/// Get all app names from the OCS source for deduplication.
fn get_ocs_source_names(db: &Database) -> std::collections::HashSet<String> {
    let sources = db.get_catalog_sources().unwrap_or_default();
    for src in &sources {
        if src.source_type == "ocs-appimagehub" {
            return db.get_catalog_app_names_for_source(src.id).unwrap_or_default();
        }
    }
    std::collections::HashSet::new()
}

/// Sync the OCS AppImageHub catalog (primary source).
fn sync_ocs_catalog(
    db: &Database,
    source: &CatalogSource,
    on_progress: &dyn Fn(SyncProgress),
) -> Result<u32, CatalogError> {
    let source_id = source.id.ok_or(CatalogError::NoSourceId)?;
    let items = fetch_ocs_catalog(on_progress)?;

    let total = items.len() as u32;
    on_progress(SyncProgress::FeedFetched { total });

    let mut count = 0u32;
    for (app, extra) in &items {
        count += 1;
        if count % 50 == 0 || count == total {
            on_progress(SyncProgress::SavingApps { current: count, total });
        }

        let screenshots_str = if app.screenshots.is_empty() {
            None
        } else {
            Some(app.screenshots.join(";"))
        };

        db.insert_ocs_catalog_app(
            source_id,
            &app.name,
            extra.ocs_id,
            extra.ocs_description.as_deref(),
            extra.ocs_summary.as_deref(),
            Some(app.categories.join(";")).as_deref().filter(|s| !s.is_empty()),
            extra.ocs_version.as_deref(),
            &app.download_url,
            app.icon_url.as_deref(),
            app.homepage.as_deref(),
            screenshots_str.as_deref(),
            extra.ocs_downloads,
            extra.ocs_score,
            extra.ocs_typename.as_deref(),
            extra.ocs_personid.as_deref(),
            extra.ocs_tags.as_deref(),
            extra.ocs_changed.as_deref(),
            extra.ocs_preview_url.as_deref(),
            app.license.as_deref(),
            extra.ocs_detailpage.as_deref(),
            extra.ocs_created.as_deref(),
            extra.ocs_downloadname.as_deref(),
            extra.ocs_downloadsize,
            extra.ocs_arch.as_deref(),
            extra.ocs_md5sum.as_deref(),
            extra.ocs_comments,
        ).ok();

        // Try to extract GitHub owner/repo from github_link (extracted from description),
        // homepage, or download URL
        let github_result = app.github_link.as_deref()
            .and_then(|gl| github_enrichment::extract_github_repo(Some(gl), ""))
            .or_else(|| github_enrichment::extract_github_repo(
                app.homepage.as_deref(),
                &app.download_url,
            ));
        if let Some((owner, repo)) = github_result {
            if let Ok(Some(db_app)) = db.get_catalog_app_by_source_and_name(source_id, &app.name) {
                db.update_catalog_app_github_repo(db_app, &owner, &repo).ok();
            }
        }
    }

    // Clear stale screenshot cache so new screenshot indices map correctly
    clear_screenshot_cache();

    // Clear and re-cache icons for OCS apps (URLs may have changed)
    let ocs_apps: Vec<CatalogApp> = items.into_iter().map(|(a, _)| a).collect();
    clear_ocs_icon_cache(&ocs_apps);
    let icon_count = cache_catalog_icons_with_progress(&ocs_apps, on_progress);
    log::info!("Cached {} OCS catalog icons", icon_count);

    db.update_catalog_source_sync(source_id, count as i32).ok();
    on_progress(SyncProgress::Done { total: count });
    Ok(count)
}

/// Fetch all apps from the OCS API, paginating through all pages.
fn fetch_ocs_catalog(
    _on_progress: &dyn Fn(SyncProgress),
) -> Result<Vec<(CatalogApp, OcsExtra)>, CatalogError> {
    let mut all_items = Vec::new();
    let mut page = 0u32;

    loop {
        let url = format!(
            "{}?format=json&pagesize={}&page={}",
            OCS_API_URL, OCS_PAGE_SIZE, page
        );
        let response = ureq::get(&url)
            .call()
            .map_err(|e| CatalogError::Network(format!("OCS fetch page {} failed: {}", page, e)))?;

        let body = response.into_body().read_to_string()
            .map_err(|e| CatalogError::Network(e.to_string()))?;

        let ocs_resp: OcsResponse = serde_json::from_str(&body)
            .map_err(|e| CatalogError::Parse(format!("OCS JSON parse failed on page {}: {}", page, e)))?;

        if ocs_resp.data.is_empty() {
            break;
        }

        let page_count = ocs_resp.data.len();

        for item in ocs_resp.data {
            // Skip items with empty name, no id, or no download link
            if item.name.trim().is_empty() {
                continue;
            }
            let ocs_id = match item.id {
                Some(id) => id,
                None => continue,
            };
            let download_url = match item.downloadlink1 {
                Some(ref dl) if !dl.is_empty() => dl.clone(),
                _ => continue,
            };

            // Skip non-AppImage downloads (e.g. .dmg, .exe, .rpm, .zip)
            let pkg_type = item.download_package_type1.as_deref().unwrap_or("");
            if !pkg_type.is_empty() && pkg_type != "appimage" {
                continue;
            }
            // Also check filename extension as fallback
            if let Some(ref dname) = item.downloadname1 {
                let lower = dname.to_lowercase();
                if lower.ends_with(".dmg") || lower.ends_with(".exe")
                    || lower.ends_with(".rpm") || lower.ends_with(".deb")
                    || lower.ends_with(".zip") || lower.ends_with(".msi")
                    || lower.ends_with(".pkg") || lower.ends_with(".7z")
                {
                    continue;
                }
            }

            let ocs_downloads = item.downloads
                .as_deref()
                .and_then(|s| s.parse::<i64>().ok());

            // previewpic1 is always the app icon/logo on appimagehub.com.
            // Actual screenshots start from previewpic2 onward.
            let screenshots: Vec<String> = [
                item.previewpic2.as_deref(),
                item.previewpic3.as_deref(),
                item.previewpic4.as_deref(),
                item.previewpic5.as_deref(),
                item.previewpic6.as_deref(),
            ]
            .iter()
            .filter_map(|p| p.filter(|s| !s.is_empty()).map(|s| s.to_string()))
            .collect();

            let categories = map_ocs_category(item.typename.as_deref().unwrap_or(""));

            // Extract GitHub link from HTML description if present
            let github_link = item.description.as_deref()
                .and_then(extract_github_link_from_html);

            // Use detailpage as homepage since OCS API has no homepage field
            let homepage = item.detailpage.clone();

            // Use smallpreviewpic1 as icon, fall back to previewpic1 (which is the icon/logo).
            // Shrink from 770x540 to 100x100 since we only display at 48px.
            let icon_url = item.smallpreviewpic1.clone()
                .filter(|s| !s.is_empty())
                .or_else(|| item.previewpic1.clone().filter(|s| !s.is_empty()))
                .map(|url| shrink_ocs_image_url(&url, "100x100"));

            let catalog_app = CatalogApp {
                name: item.name.clone(),
                description: item.summary.clone().or(item.description.clone()),
                categories,
                latest_version: item.version.clone(),
                download_url,
                icon_url,
                homepage,
                file_size: item.downloadsize1.map(|s| (s * 1024) as u64), // API gives KB, we store bytes
                architecture: item.download_package_arch1.clone(),
                screenshots,
                license: None,
                github_link,
            };

            let extra = OcsExtra {
                ocs_id,
                ocs_downloads,
                ocs_score: item.score.map(|s| s as i64),
                ocs_typename: item.typename,
                ocs_personid: item.personid,
                ocs_description: item.description,
                ocs_summary: item.summary,
                ocs_version: item.version,
                ocs_tags: item.tags,
                ocs_changed: item.changed,
                ocs_preview_url: item.previewpic1,
                ocs_detailpage: item.detailpage,
                ocs_created: item.created,
                ocs_downloadname: item.downloadname1,
                ocs_downloadsize: item.downloadsize1,
                ocs_arch: item.download_package_arch1,
                ocs_md5sum: item.downloadmd5sum1,
                ocs_comments: item.comments,
            };

            all_items.push((catalog_app, extra));
        }

        log::info!("OCS page {}: {} items (total so far: {})", page, page_count, all_items.len());

        page += 1;
    }

    log::info!("OCS catalog fetch complete: {} apps total", all_items.len());
    Ok(all_items)
}

/// Extract a GitHub repository URL from OCS HTML description.
/// Many OCS app descriptions contain links to their GitHub repo.
fn extract_github_link_from_html(html: &str) -> Option<String> {
    // Find "github.com/" in the text (case-insensitive search)
    let lower = html.to_lowercase();
    let marker = "github.com/";
    let idx = lower.find(marker)?;

    // Walk backwards from idx to find the URL scheme (https:// or http://)
    let before = &html[..idx];
    let scheme_start = before.rfind("https://").or_else(|| before.rfind("http://"))?;
    // Make sure the scheme is close to the github.com part (no intervening garbage)
    if idx - scheme_start > 20 {
        return None;
    }

    // From github.com/, extract owner/repo
    let after_marker = &html[idx + marker.len()..];
    // Take characters until we hit whitespace, quotes, <, >, or end
    let end = after_marker.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '<' || c == '>' || c == ')').unwrap_or(after_marker.len());
    let path = &after_marker[..end];

    // Split by / to get owner/repo (ignore further path components)
    let parts: Vec<&str> = path.splitn(3, '/').collect();
    if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
        Some(format!("https://github.com/{}/{}", parts[0], parts[1]))
    } else {
        None
    }
}

/// Map OCS typename to clean categories via exhaustive match on known typenames.
pub(crate) fn map_ocs_category(typename: &str) -> Vec<String> {
    if typename.is_empty() {
        return Vec::new();
    }
    let cat = match typename.to_lowercase().as_str() {
        // Audio
        "audio apps" | "audioplayers" | "radio apps" | "music production"
        | "dj apps" | "audio extractors/converters" | "audacious" => "Audio",
        // Video (includes multimedia/streaming)
        "video apps" | "video players" | "video editors" | "video converters"
        | "webcam & monitoring" | "tv & streaming" | "screen recorders" => "Video",
        // Game
        "various games" | "tactics & strategy" | "shooter" | "jump n run"
        | "arcade" | "racing" | "chess" | "board" | "shoot em up" | "card"
        | "abstract" | "nature" | "animals" | "emulation" => "Game",
        // Graphics (includes photography)
        "graphic apps" | "3d graphics" | "vector graphics" | "image viewers"
        | "screenshot tools" | "wallpaper other" | "plasma 5 wallpaper plugins"
        | "gimp splashes" | "mountains" | "landscapes" => "Graphics",
        // Development
        "developers apps" | "ides" | "programming" | "qt widgets"
        | "database" => "Development",
        // Network (includes communication)
        "internet apps" | "browser" | "chat & messenging" | "p2p & torrent"
        | "email" | "vpn" | "network" | "phone utilities" | "telephony"
        | "phone roms" | "groupware" => "Network",
        // Office (includes productivity)
        "office apps" | "notes" | "text editors" | "ebook software"
        | "pdf software" | "printing" => "Office",
        // Finance
        "financial" | "crypto" => "Finance",
        // Science (includes education)
        "education apps" | "e-learning" | "science" => "Science",
        // System (includes security)
        "system tools" | "system monitoring" | "terminal apps"
        | "virtualization" | "iot"
        | "system tools (not os or roms)" | "security" => "System",
        // Utility
        "utilities" | "file management" => "Utility",
        // Unknown - pass through as-is
        _ => return vec![typename.to_string()],
    };
    vec![cat.to_string()]
}

/// Normalize a single FreeDesktop category name to one of our clean groups.
/// Returns None if the category should be dropped (too generic or meta).
pub(crate) fn normalize_category(cat: &str) -> Option<&'static str> {
    match cat.to_lowercase().as_str() {
        // Our canonical names pass through
        "audio" => Some("Audio"),
        "video" | "multimedia" => Some("Video"),
        "game" => Some("Game"),
        "graphics" | "photography" => Some("Graphics"),
        "development" => Some("Development"),
        "network" | "communication" => Some("Network"),
        "office" | "productivity" => Some("Office"),
        "finance" => Some("Finance"),
        "science" | "education" => Some("Science"),
        "system" | "security" => Some("System"),
        "utility" => Some("Utility"),

        // Audio
        "music" | "midi" | "mixer" | "sequencer" | "tuner" => Some("Audio"),
        // Video (includes multimedia, streaming)
        "tv" | "audiovideo" | "player" | "recorder" => Some("Video"),
        // Network (includes communication)
        "email" | "instantmessaging" | "ircclient"
        | "telephony" | "chat" | "videochat" | "videoconference"
        | "feed" | "news" | "filetransfer" | "hamradio"
        | "p2p" | "remoteaccess" | "webbrowser" => Some("Network"),
        // Graphics (includes photography)
        "2dgraphics" | "3dgraphics" | "vectorgraphics" | "rastergraphics"
        | "publishing" | "imageprocessing" | "art"
        | "viewer" | "scanning" | "ocr" => Some("Graphics"),
        // Office (includes productivity)
        "database" | "spreadsheet" | "wordprocessor"
        | "presentation" | "texteditor" | "dictionary"
        | "calendar" | "contactmanagement"
        | "projectmanagement" => Some("Office"),
        // Game
        "actiongame" | "adventuregame" | "arcadegame" | "boardgame"
        | "blocksgame" | "cardgame" | "kidsgame" | "logicgame"
        | "roleplaying" | "shooter" | "simulation" | "sportsgame"
        | "strategygame" => Some("Game"),
        // Development
        "building" | "debugger" | "ide" | "profiling"
        | "revisioncontrol" | "translation" | "webdevelopment" => Some("Development"),
        // Science (includes education)
        "computerscience" | "datavisualization"
        | "electricity" | "geography"
        | "geology" | "geoscience"
        | "maps" | "math"
        | "numericalanalysis" | "medicalsoftware"
        | "physics" | "robotics" | "parallelcomputing"
        | "astronomy" | "biology" | "chemistry"
        | "artificialintelligence" | "electronics"
        | "engineering" | "construction"
        | "languages" | "history" | "humanities"
        | "literature" | "sports" => Some("Science"),
        // Finance
        "economy" => Some("Finance"),
        // Utility
        "archiving" | "compression" | "filetools"
        | "filemanager" | "terminalemulator" | "filesystem"
        | "monitor" | "accessibility" | "calculator"
        | "clock" | "texttools" => Some("Utility"),
        // System (includes security)
        "emulator" | "virtualization" | "packagemanager"
        | "settings" => Some("System"),

        // Meta/noise categories to drop
        "application" | "gnome" | "gtk" | "kde" | "qt"
        | "x-tool" | "java" | "documentation"
        | "core" | "freedesktop" | "shell" => None,

        // Unknown - drop rather than show raw
        _ => None,
    }
}

/// Normalize a list of categories: map each to our clean groups, deduplicate.
/// Categories that don't map to any known group are dropped.
pub(crate) fn normalize_categories(cats: Vec<String>) -> Vec<String> {
    let mut result = Vec::new();
    for cat in &cats {
        if let Some(normalized) = normalize_category(cat) {
            if !result.iter().any(|r: &String| r == normalized) {
                result.push(normalized.to_string());
            }
        }
    }
    result
}

/// Resolve a fresh download URL for an OCS app at install time.
/// OCS download links are JWT-authenticated and expire, so we fetch a fresh one.
/// `slot` is the 1-based download file slot (default 1 for the primary file).
pub fn resolve_ocs_download(ocs_id: i64, slot: u32) -> Result<String, CatalogError> {
    let url = format!(
        "https://api.appimagehub.com/ocs/v1/content/download/{}/{}?format=json",
        ocs_id, slot
    );

    let response = ureq::get(&url)
        .call()
        .map_err(|e| CatalogError::Network(format!("OCS download resolve failed: {}", e)))?;

    let body = response.into_body().read_to_string()
        .map_err(|e| CatalogError::Network(e.to_string()))?;

    let dl_resp: OcsDownloadResponse = serde_json::from_str(&body)
        .map_err(|e| CatalogError::Parse(format!("OCS download JSON parse failed: {}", e)))?;

    dl_resp.data.first()
        .and_then(|item| item.download_link.clone())
        .filter(|link| !link.is_empty())
        .ok_or_else(|| CatalogError::Network("No download link in OCS response".into()))
}

/// Fetch all available download files for an OCS content item.
/// Returns only AppImage files, sorted with newest version first.
pub fn fetch_ocs_download_files(ocs_id: i64) -> Result<Vec<OcsDownloadFile>, CatalogError> {
    let url = format!(
        "https://api.appimagehub.com/ocs/v1/content/data/{}?format=json",
        ocs_id
    );

    let response = ureq::get(&url)
        .call()
        .map_err(|e| CatalogError::Network(format!("OCS files fetch failed: {}", e)))?;

    let body = response.into_body().read_to_string()
        .map_err(|e| CatalogError::Network(e.to_string()))?;

    let json: serde_json::Value = serde_json::from_str(&body)
        .map_err(|e| CatalogError::Parse(format!("OCS files JSON parse failed: {}", e)))?;

    let data = json.get("data")
        .and_then(|d| d.as_array())
        .ok_or_else(|| CatalogError::Parse("No data array in OCS response".into()))?;

    let item = data.first()
        .ok_or_else(|| CatalogError::Parse("Empty data array in OCS response".into()))?;

    let mut files = Vec::new();

    for slot in 1..=20u32 {
        let link_key = format!("downloadlink{}", slot);
        let name_key = format!("downloadname{}", slot);

        // Stop if no download link for this slot
        let link = match item.get(&link_key).and_then(|v| v.as_str()) {
            Some(l) if !l.is_empty() => l,
            _ => break,
        };

        let filename = item.get(&name_key)
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();

        // Skip non-AppImage files
        let pkg_type_key = format!("download_package_type{}", slot);
        let pkg_type = item.get(&pkg_type_key)
            .and_then(|v| v.as_str())
            .unwrap_or("");
        if !pkg_type.is_empty() && pkg_type != "appimage" {
            continue;
        }
        let lower_name = filename.to_lowercase();
        if lower_name.ends_with(".dmg") || lower_name.ends_with(".exe")
            || lower_name.ends_with(".rpm") || lower_name.ends_with(".deb")
            || lower_name.ends_with(".zip") || lower_name.ends_with(".msi")
            || lower_name.ends_with(".pkg") || lower_name.ends_with(".7z")
        {
            continue;
        }

        let version_key = format!("download_version{}", slot);
        let version = item.get(&version_key)
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();

        let size_key = format!("downloadsize{}", slot);
        let size_kb = item.get(&size_key)
            .and_then(|v| v.as_str().and_then(|s| s.parse().ok()).or_else(|| v.as_i64()));

        let arch_key = format!("download_package_arch{}", slot);
        let arch = item.get(&arch_key)
            .and_then(|v| v.as_str())
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string());

        // Ignore the actual download link (JWT, may be expired) - we resolve fresh at install time
        let _ = link;

        files.push(OcsDownloadFile {
            slot,
            ocs_id,
            filename,
            version,
            size_kb,
            arch,
            pkg_type: if pkg_type.is_empty() { None } else { Some(pkg_type.to_string()) },
        });
    }

    // Sort: newest version first (by slot descending since newer versions are typically added last)
    files.reverse();

    Ok(files)
}

/// Fetch the AppImageHub feed and parse it into CatalogApp entries.
fn fetch_appimage_hub() -> Result<Vec<CatalogApp>, CatalogError> {
    let response = ureq::get(APPIMAGEHUB_API_URL)
        .call()
        .map_err(|e| CatalogError::Network(format!("AppImageHub fetch failed: {}", e)))?;

    let body = response.into_body().read_to_string()
        .map_err(|e| CatalogError::Network(e.to_string()))?;

    let feed: AppImageHubFeed = serde_json::from_str(&body)
        .map_err(|e| CatalogError::Parse(format!("AppImageHub JSON parse failed: {}", e)))?;

    let apps: Vec<CatalogApp> = feed.items.into_iter().filter_map(|item| {
        // AppImageHub items need at least a name and a link
        let name = item.name?;
        let links = item.links.unwrap_or_default();
        let download_url = links.iter()
            .find(|l| l.r#type == "Download")
            .map(|l| l.url.clone())?;

        // Extract GitHub link from feed links
        let github_link = links.iter()
            .find(|l| l.r#type.to_lowercase().contains("github"))
            .map(|l| l.url.clone());

        Some(CatalogApp {
            name,
            description: item.description,
            categories: normalize_categories(item.categories.unwrap_or_default().into_iter().flatten().collect()),
            latest_version: None,
            download_url,
            icon_url: item.icons.and_then(|icons| icons.into_iter().flatten().next()),
            homepage: item.authors.and_then(|a| {
                let first = a.into_iter().next()?;
                if let Some(ref author_name) = first.name {
                    log::debug!("Catalog app author: {}", author_name);
                }
                first.url
            }),
            file_size: None,
            architecture: None,
            screenshots: item.screenshots.unwrap_or_default().into_iter().flatten().collect(),
            license: item.license,
            github_link,
        })
    }).collect();

    Ok(apps)
}

/// Fetch a custom catalog from a URL (expects a JSON array of CatalogApp-like objects).
fn fetch_custom_catalog(url: &str) -> Result<Vec<CatalogApp>, CatalogError> {
    let response = ureq::get(url)
        .call()
        .map_err(|e| CatalogError::Network(e.to_string()))?;

    let body = response.into_body().read_to_string()
        .map_err(|e| CatalogError::Network(e.to_string()))?;

    let items: Vec<CustomCatalogEntry> = serde_json::from_str(&body)
        .map_err(|e| CatalogError::Parse(e.to_string()))?;

    Ok(items.into_iter().map(|item| CatalogApp {
        name: item.name,
        description: item.description,
        categories: normalize_categories(item.categories.unwrap_or_default()),
        latest_version: item.version,
        download_url: item.download_url,
        icon_url: item.icon_url,
        homepage: item.homepage,
        file_size: item.file_size,
        architecture: item.architecture,
        screenshots: Vec::new(),
        license: None,
        github_link: None,
    }).collect())
}

/// Ensure the default catalog sources exist in the database.
/// OCS AppImageHub.com is the primary source (richer metadata), and
/// appimage.github.io is the secondary source for apps not in OCS.
pub fn ensure_default_sources(db: &Database) {
    // Primary: OCS AppImageHub.com (insert first so it syncs first)
    db.upsert_catalog_source(
        "AppImageHub Catalog",
        OCS_API_URL,
        "ocs-appimagehub",
    ).ok();

    // Secondary: appimage.github.io feed
    db.upsert_catalog_source(
        "Community Feed",
        APPIMAGEHUB_API_URL,
        "appimage-hub",
    ).ok();
}

/// Get all catalog sources from the database.
pub fn get_sources(db: &Database) -> Vec<CatalogSource> {
    let records = db.get_catalog_sources().unwrap_or_default();
    records.into_iter().map(|r| CatalogSource {
        id: Some(r.id),
        name: r.name,
        url: r.url,
        source_type: CatalogType::from_str(&r.source_type),
        enabled: r.enabled,
        last_synced: r.last_synced,
        app_count: r.app_count,
    }).collect()
}

/// Base URL for AppImageHub database assets (icons, screenshots).
pub const APPIMAGEHUB_DATABASE_URL: &str = "https://appimage.github.io/database/";

/// Get the icon cache directory, creating it if needed.
pub fn icon_cache_dir() -> PathBuf {
    let dir = dirs::cache_dir()
        .unwrap_or_else(|| PathBuf::from("/tmp"))
        .join("driftwood")
        .join("icons");
    fs::create_dir_all(&dir).ok();
    dir
}

/// Get the screenshot cache directory, creating it if needed.
pub fn screenshot_cache_dir() -> PathBuf {
    let dir = dirs::cache_dir()
        .unwrap_or_else(|| PathBuf::from("/tmp"))
        .join("driftwood")
        .join("screenshots");
    fs::create_dir_all(&dir).ok();
    dir
}

/// Shrink an OCS CDN image URL to a smaller cache size.
/// OCS URLs look like https://images.pling.com/cache/770x540-4/img/...
/// We can replace the size portion to get smaller images.
fn shrink_ocs_image_url(url: &str, size: &str) -> String {
    if let Some(start) = url.find("/cache/") {
        let after_cache = start + "/cache/".len();
        if let Some(end) = url[after_cache..].find('/') {
            let mut result = String::with_capacity(url.len());
            result.push_str(&url[..after_cache]);
            result.push_str(size);
            result.push_str(&url[after_cache + end..]);
            return result;
        }
    }
    url.to_string()
}

/// Clear the screenshot cache directory to force re-download of screenshots.
/// Called during catalog sync to avoid stale cached images.
fn clear_screenshot_cache() {
    let cache_dir = screenshot_cache_dir();
    if cache_dir.exists() {
        fs::remove_dir_all(&cache_dir).ok();
        fs::create_dir_all(&cache_dir).ok();
    }
}

/// Clear icon cache entries for OCS apps to force re-download with updated URLs.
fn clear_ocs_icon_cache(apps: &[CatalogApp]) {
    let cache_dir = icon_cache_dir();
    for app in apps {
        let sanitized = sanitize_filename(&app.name);
        let path = cache_dir.join(format!("{}.png", sanitized));
        if path.exists() {
            fs::remove_file(&path).ok();
        }
    }
}

/// Resolve an asset path to a full URL (handles relative paths from AppImageHub).
fn resolve_asset_url(path: &str) -> String {
    if path.starts_with("http://") || path.starts_with("https://") {
        path.to_string()
    } else {
        format!("{}{}", APPIMAGEHUB_DATABASE_URL, path)
    }
}

/// Download a file from a URL to a local path.
fn download_file(url: &str, dest: &Path) -> Result<(), CatalogError> {
    let response = ureq::get(url)
        .config()
        .timeout_global(Some(std::time::Duration::from_secs(15)))
        .build()
        .call()
        .map_err(|e| CatalogError::Network(e.to_string()))?;

    let mut file = fs::File::create(dest)
        .map_err(|e| CatalogError::Io(e.to_string()))?;

    let mut reader = response.into_body().into_reader();
    let mut buf = [0u8; 65536];
    loop {
        let n = reader.read(&mut buf)
            .map_err(|e| CatalogError::Network(e.to_string()))?;
        if n == 0 { break; }
        file.write_all(&buf[..n])
            .map_err(|e| CatalogError::Io(e.to_string()))?;
    }

    Ok(())
}

/// Sanitize a name for use as a filename.
pub fn sanitize_filename(name: &str) -> String {
    name.chars()
        .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
        .collect()
}

/// Download icons for all catalog apps that have icon_url set.
/// Saves to ~/.cache/driftwood/icons/{sanitized_name}.png
#[allow(dead_code)]
fn cache_catalog_icons(apps: &[CatalogApp]) -> u32 {
    cache_catalog_icons_with_progress(apps, &|_| {})
}

fn cache_catalog_icons_with_progress(apps: &[CatalogApp], on_progress: &dyn Fn(SyncProgress)) -> u32 {
    let cache_dir = icon_cache_dir();
    let mut count = 0u32;
    let total = apps.len() as u32;

    for (i, app) in apps.iter().enumerate() {
        on_progress(SyncProgress::CachingIcon {
            current: i as u32 + 1,
            total,
            app_name: app.name.clone(),
        });

        if let Some(ref icon_url) = app.icon_url {
            let sanitized = sanitize_filename(&app.name);
            let dest = cache_dir.join(format!("{}.png", sanitized));

            // Skip if already cached
            if dest.exists() {
                count += 1;
                continue;
            }

            let url = resolve_asset_url(icon_url);
            match download_file(&url, &dest) {
                Ok(_) => {
                    count += 1;
                    log::debug!("Cached icon for {}", app.name);
                }
                Err(e) => {
                    log::debug!("Failed to cache icon for {}: {}", app.name, e);
                }
            }
        }
    }

    count
}

/// Download a screenshot to the cache. Returns the local path on success.
pub fn cache_screenshot(app_name: &str, screenshot_path: &str, index: usize) -> Result<PathBuf, CatalogError> {
    let cache_dir = screenshot_cache_dir();
    let sanitized = sanitize_filename(app_name);
    let dest = cache_dir.join(format!("{}_{}.png", sanitized, index));

    if dest.exists() {
        return Ok(dest);
    }

    let url = resolve_asset_url(screenshot_path);
    download_file(&url, &dest)?;
    Ok(dest)
}

// --- AppImageHub feed format ---

#[derive(Debug, serde::Deserialize)]
struct AppImageHubFeed {
    items: Vec<AppImageHubItem>,
}

#[derive(Debug, serde::Deserialize)]
struct AppImageHubItem {
    name: Option<String>,
    description: Option<String>,
    categories: Option<Vec<Option<String>>>,
    authors: Option<Vec<AppImageHubAuthor>>,
    links: Option<Vec<AppImageHubLink>>,
    icons: Option<Vec<Option<String>>>,
    screenshots: Option<Vec<Option<String>>>,
    license: Option<String>,
}

#[derive(Debug, serde::Deserialize)]
struct AppImageHubAuthor {
    name: Option<String>,
    url: Option<String>,
}

#[derive(Debug, serde::Deserialize)]
struct AppImageHubLink {
    r#type: String,
    url: String,
}

// --- Custom catalog entry format ---

#[derive(Debug, serde::Deserialize)]
struct CustomCatalogEntry {
    name: String,
    description: Option<String>,
    categories: Option<Vec<String>>,
    version: Option<String>,
    download_url: String,
    icon_url: Option<String>,
    homepage: Option<String>,
    file_size: Option<u64>,
    architecture: Option<String>,
}

// --- Error types ---

#[derive(Debug)]
pub enum CatalogError {
    Network(String),
    Parse(String),
    Io(String),
    NoSourceId,
}

impl std::fmt::Display for CatalogError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Network(e) => write!(f, "Network error: {}", e),
            Self::Parse(e) => write!(f, "Parse error: {}", e),
            Self::Io(e) => write!(f, "I/O error: {}", e),
            Self::NoSourceId => write!(f, "Catalog source has no ID"),
        }
    }
}

use std::io::Read;

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

    #[test]
    fn test_catalog_type_roundtrip() {
        assert_eq!(CatalogType::from_str("appimage-hub"), CatalogType::AppImageHub);
        assert_eq!(CatalogType::from_str("ocs-appimagehub"), CatalogType::OcsAppImageHub);
        assert_eq!(CatalogType::from_str("github-search"), CatalogType::GitHubSearch);
        assert_eq!(CatalogType::from_str("custom"), CatalogType::Custom);
        assert_eq!(CatalogType::from_str("unknown"), CatalogType::Custom);
    }

    #[test]
    fn test_catalog_type_as_str() {
        assert_eq!(CatalogType::AppImageHub.as_str(), "appimage-hub");
        assert_eq!(CatalogType::OcsAppImageHub.as_str(), "ocs-appimagehub");
        assert_eq!(CatalogType::GitHubSearch.as_str(), "github-search");
        assert_eq!(CatalogType::Custom.as_str(), "custom");
    }

    #[test]
    fn test_catalog_error_display() {
        let err = CatalogError::Network("timeout".to_string());
        assert!(format!("{}", err).contains("timeout"));
        let err = CatalogError::NoSourceId;
        assert!(format!("{}", err).contains("no ID"));
    }

    #[test]
    fn test_ensure_default_sources() {
        let db = crate::core::database::Database::open_in_memory().unwrap();
        ensure_default_sources(&db);
        let sources = get_sources(&db);
        assert_eq!(sources.len(), 2);
        let ocs = sources.iter().find(|s| s.source_type == CatalogType::OcsAppImageHub);
        assert!(ocs.is_some(), "OCS source should exist");
        assert_eq!(ocs.unwrap().name, "AppImageHub.com");
        let hub = sources.iter().find(|s| s.source_type == CatalogType::AppImageHub);
        assert!(hub.is_some(), "AppImageHub source should exist");
    }

    #[test]
    fn test_search_catalog_empty() {
        let db = crate::core::database::Database::open_in_memory().unwrap();
        let results = db.search_catalog_apps("firefox").unwrap();
        assert!(results.is_empty());
    }

    #[test]
    fn test_get_sources_empty() {
        let db = crate::core::database::Database::open_in_memory().unwrap();
        let sources = get_sources(&db);
        assert!(sources.is_empty());
    }
}
