use super::cjk::{cjk_region_families, cjk_region_filename, cjk_region_urls, collect_cjk_regions};
use super::constants::{
    AUTO_FALLBACK_EMOJI, DEFAULT_GITHUB_PROXIES, NOTOFONTS_STATE_URL, NOTO_EMOJI_URLS,
};
use super::dirs::{default_app_dir, resolve_font_dirs};
use super::models::{FontFallbackNeeds, ScriptDownload, ScriptFontPlan};
use super::system::{family_key, load_app_font_families, load_system_font_families};
use crate::{Config, Error, FontSystemFallback, Result};
use once_cell::sync::Lazy;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;

#[derive(Debug, Clone, Deserialize)]
pub(crate) struct NotofontsState(pub(crate) HashMap<String, NotofontsRepo>);

#[derive(Debug, Clone, Deserialize)]
pub(crate) struct NotofontsRepo {
    #[serde(default)]
    pub(crate) families: HashMap<String, NotofontsFamily>,
}

#[derive(Debug, Clone, Deserialize)]
pub(crate) struct NotofontsFamily {
    #[serde(default)]
    pub(crate) latest_release: Option<NotofontsRelease>,
    #[serde(default)]
    pub(crate) files: Vec<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub(crate) struct NotofontsRelease {
    pub(crate) url: String,
}

static NOTOFONTS_STATE: Lazy<Mutex<Option<Arc<NotofontsState>>>> = Lazy::new(|| Mutex::new(None));
static HTTP_AGENT: Lazy<ureq::Agent> = Lazy::new(|| {
    ureq::AgentBuilder::new()
        .timeout(Duration::from_secs(600))
        .build()
});

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum DownloadLogLevel {
    Off = 0,
    Error = 1,
    Warn = 2,
    Info = 3,
    Debug = 4,
    Trace = 5,
}

pub(crate) fn parse_download_log_level(value: &str) -> Option<DownloadLogLevel> {
    let normalized = value.trim().to_ascii_lowercase();
    match normalized.as_str() {
        "" => None,
        "off" | "none" | "0" | "false" | "no" => Some(DownloadLogLevel::Off),
        "error" | "err" | "1" => Some(DownloadLogLevel::Error),
        "warn" | "warning" | "2" => Some(DownloadLogLevel::Warn),
        "info" | "3" => Some(DownloadLogLevel::Info),
        "debug" | "dbg" | "4" => Some(DownloadLogLevel::Debug),
        "trace" | "5" => Some(DownloadLogLevel::Trace),
        _ => None,
    }
}

pub(crate) fn download_log_level() -> DownloadLogLevel {
    if let Ok(value) = env::var("CRYOSNAP_FONT_LOG") {
        if let Some(level) = parse_download_log_level(&value) {
            return level;
        }
    }
    if let Ok(value) = env::var("CRYOSNAP_LOG") {
        if let Some(level) = parse_download_log_level(&value) {
            return level;
        }
    }
    DownloadLogLevel::Info
}

pub(crate) fn download_log(level: DownloadLogLevel, message: impl AsRef<str>) {
    if matches!(level, DownloadLogLevel::Off) {
        return;
    }
    let current = download_log_level();
    if level > current {
        return;
    }
    let label = match level {
        DownloadLogLevel::Error => "error",
        DownloadLogLevel::Warn => "warn",
        DownloadLogLevel::Info => "info",
        DownloadLogLevel::Debug => "debug",
        DownloadLogLevel::Trace => "trace",
        DownloadLogLevel::Off => "off",
    };
    eprintln!("cryosnap [{label}]: {}", message.as_ref());
}

pub(crate) fn auto_download_enabled(config: &Config) -> bool {
    if let Ok(value) = env::var("CRYOSNAP_FONT_AUTO_DOWNLOAD") {
        let value = value.trim().to_ascii_lowercase();
        return !(value == "0" || value == "false" || value == "no" || value == "off");
    }
    config.font.auto_download
}

pub(crate) fn force_update_enabled(config: &Config) -> bool {
    if let Ok(value) = env::var("CRYOSNAP_FONT_FORCE_UPDATE") {
        let value = value.trim().to_ascii_lowercase();
        return !(value == "0" || value == "false" || value == "no" || value == "off");
    }
    config.font.force_update
}

pub(crate) fn github_proxy_candidates() -> Vec<String> {
    if let Ok(value) = env::var("CRYOSNAP_GITHUB_PROXY") {
        let parts = value
            .split(',')
            .map(|v| v.trim())
            .filter(|v| !v.is_empty())
            .map(|v| v.to_string())
            .collect::<Vec<_>>();
        if !parts.is_empty() {
            return parts;
        }
    }
    DEFAULT_GITHUB_PROXIES
        .iter()
        .map(|v| v.to_string())
        .collect()
}

pub(crate) fn cache_dir() -> Result<PathBuf> {
    Ok(default_app_dir()?.join("cache"))
}

pub(crate) fn apply_github_proxy(url: &str, proxy: &str) -> String {
    let mut base = proxy.trim().to_string();
    if !base.ends_with('/') {
        base.push('/');
    }
    format!("{base}{url}")
}

pub(crate) enum FetchOutcome {
    Ok(Box<ureq::Response>, Option<String>),
    NotModified,
}

pub(crate) fn build_github_candidates() -> Vec<Option<String>> {
    let mut seen = HashSet::new();
    let mut candidates = Vec::new();

    candidates.push(None);
    for proxy in github_proxy_candidates() {
        if seen.insert(proxy.clone()) {
            candidates.push(Some(proxy));
        }
    }
    candidates
}

pub(crate) fn looks_like_json(bytes: &[u8]) -> bool {
    for &b in bytes {
        if !b.is_ascii_whitespace() {
            return b == b'{' || b == b'[';
        }
    }
    false
}

pub(crate) fn fetch_with_candidates(url: &str, headers: &[(&str, &str)]) -> Result<FetchOutcome> {
    let candidates = build_github_candidates();

    let mut last_error: Option<String> = None;
    for proxy_opt in candidates {
        let target = match &proxy_opt {
            Some(proxy) => apply_github_proxy(url, proxy),
            None => url.to_string(),
        };
        download_log(
            DownloadLogLevel::Debug,
            format!(
                "fetching {} via {}",
                url,
                proxy_opt.as_deref().unwrap_or("direct")
            ),
        );
        let mut req = HTTP_AGENT
            .get(&target)
            .set("User-Agent", "cryosnap/auto-font");
        for (key, value) in headers {
            req = req.set(key, value);
        }
        match req.call() {
            Ok(resp) => {
                if resp.status() == 304 {
                    return Ok(FetchOutcome::NotModified);
                }
                return Ok(FetchOutcome::Ok(Box::new(resp), proxy_opt.clone()));
            }
            Err(ureq::Error::Status(304, _)) => return Ok(FetchOutcome::NotModified),
            Err(err) => {
                last_error = Some(format!("{err}"));
                download_log(
                    DownloadLogLevel::Debug,
                    format!(
                        "fetch failed via {}: {}",
                        proxy_opt.as_deref().unwrap_or("direct"),
                        last_error.as_deref().unwrap_or("unknown error")
                    ),
                );
                continue;
            }
        }
    }
    Err(Error::Render(format!(
        "download failed: {}",
        last_error.unwrap_or_else(|| "unknown error".to_string())
    )))
}

pub(crate) fn fetch_bytes_with_cache(
    url: &str,
    cache_name: &str,
    force_update: bool,
) -> Result<Vec<u8>> {
    let cache_dir = cache_dir()?;
    let data_path = cache_dir.join(cache_name);
    let etag_path = cache_dir.join(format!("{cache_name}.etag"));
    let mut headers: Vec<(&str, &str)> = Vec::new();
    let mut etag_holder: Option<String> = None;
    if data_path.exists() && !force_update {
        if let Ok(etag) = fs::read_to_string(&etag_path) {
            let tag = etag.trim().to_string();
            if !tag.is_empty() {
                etag_holder = Some(tag);
            }
        }
    }
    if let Some(tag) = etag_holder.as_ref() {
        headers.push(("If-None-Match", tag.as_str()));
    }
    let candidates = build_github_candidates();
    let mut last_error: Option<String> = None;
    for proxy_opt in candidates {
        let target = match &proxy_opt {
            Some(proxy) => apply_github_proxy(url, proxy),
            None => url.to_string(),
        };
        download_log(
            DownloadLogLevel::Debug,
            format!(
                "fetching {} via {}",
                url,
                proxy_opt.as_deref().unwrap_or("direct")
            ),
        );
        let mut req = HTTP_AGENT
            .get(&target)
            .set("User-Agent", "cryosnap/auto-font");
        for (key, value) in &headers {
            req = req.set(key, value);
        }
        match req.call() {
            Ok(resp) => {
                if resp.status() == 304 {
                    if data_path.exists() {
                        let cached = fs::read(&data_path)?;
                        if looks_like_json(&cached) {
                            return Ok(cached);
                        }
                        let _ = fs::remove_file(&data_path);
                        let _ = fs::remove_file(&etag_path);
                    }
                    last_error = Some("font state cache missing".to_string());
                    continue;
                }
                let etag_value = resp.header("ETag").map(|v| v.to_string());
                let mut reader = resp.into_reader();
                let mut buf = Vec::new();
                reader.read_to_end(&mut buf)?;
                if !looks_like_json(&buf) {
                    last_error = Some("invalid response".to_string());
                    continue;
                }
                if let Some(parent) = data_path.parent() {
                    fs::create_dir_all(parent)?;
                }
                fs::write(&data_path, &buf)?;
                if let Some(etag) = etag_value {
                    let _ = fs::write(&etag_path, etag.as_bytes());
                }
                return Ok(buf);
            }
            Err(ureq::Error::Status(304, _)) => {
                if data_path.exists() {
                    let cached = fs::read(&data_path)?;
                    if looks_like_json(&cached) {
                        return Ok(cached);
                    }
                    let _ = fs::remove_file(&data_path);
                    let _ = fs::remove_file(&etag_path);
                }
                last_error = Some("font state cache missing".to_string());
                continue;
            }
            Err(err) => {
                last_error = Some(format!("{err}"));
                download_log(
                    DownloadLogLevel::Debug,
                    format!(
                        "fetch failed via {}: {}",
                        proxy_opt.as_deref().unwrap_or("direct"),
                        last_error.as_deref().unwrap_or("unknown error")
                    ),
                );
                continue;
            }
        }
    }
    if data_path.exists() {
        let cached = fs::read(&data_path)?;
        if looks_like_json(&cached) {
            return Ok(cached);
        }
        let _ = fs::remove_file(&data_path);
        let _ = fs::remove_file(&etag_path);
    }
    Err(Error::Render(format!(
        "download failed: {}",
        last_error.unwrap_or_else(|| "unknown error".to_string())
    )))
}

pub(crate) fn download_url_with_etag(url: &str, target: &Path, force_update: bool) -> Result<bool> {
    let etag_path = target.with_extension(format!(
        "{}.etag",
        target
            .extension()
            .and_then(|v| v.to_str())
            .unwrap_or("font")
    ));
    let mut headers: Vec<(&str, &str)> = Vec::new();
    let mut etag_holder: Option<String> = None;
    if target.exists() && !force_update {
        if let Ok(etag) = fs::read_to_string(&etag_path) {
            let tag = etag.trim().to_string();
            if !tag.is_empty() {
                etag_holder = Some(tag);
            }
        }
    }
    if let Some(tag) = etag_holder.as_ref() {
        headers.push(("If-None-Match", tag.as_str()));
    }
    match fetch_with_candidates(url, &headers)? {
        FetchOutcome::Ok(resp, _proxy) => {
            let etag_value = resp.header("ETag").map(|v| v.to_string());
            let mut reader = resp.into_reader();
            let temp = target.with_extension("download");
            let mut file = fs::File::create(&temp)?;
            std::io::copy(&mut reader, &mut file)?;
            file.sync_all()?;
            fs::rename(&temp, target)?;
            if let Some(etag) = etag_value {
                let _ = fs::write(&etag_path, etag.as_bytes());
            }
            Ok(true)
        }
        FetchOutcome::NotModified => Ok(false),
    }
}

pub(crate) fn load_notofonts_state(force_update: bool) -> Result<Arc<NotofontsState>> {
    if !force_update {
        if let Ok(guard) = NOTOFONTS_STATE.lock() {
            if let Some(state) = guard.as_ref() {
                return Ok(state.clone());
            }
        }
    }
    let bytes = fetch_bytes_with_cache(NOTOFONTS_STATE_URL, "notofonts_state.json", force_update)?;
    let state: NotofontsState = serde_json::from_slice(&bytes)
        .map_err(|err| Error::Render(format!("font state parse: {err}")))?;
    let state = Arc::new(state);
    if !force_update {
        if let Ok(mut guard) = NOTOFONTS_STATE.lock() {
            *guard = Some(state.clone());
        }
    }
    Ok(state)
}

pub(crate) struct FontPackage {
    id: &'static str,
    family: &'static str,
    filename: &'static str,
    url: &'static str,
    download_sha256: &'static str,
    file_sha256: &'static str,
    archive_entry: Option<&'static str>,
}

pub(crate) const FONT_PACKAGE_NF: FontPackage = FontPackage {
    id: "symbols-nerd-font-mono",
    family: "Symbols Nerd Font Mono",
    filename: "SymbolsNerdFontMono-Regular.ttf",
    url:
        "https://github.com/ryanoasis/nerd-fonts/releases/download/v3.2.1/NerdFontsSymbolsOnly.zip",
    download_sha256: "bc59c2ea74d022a6262ff9e372fde5c36cd5ae3f82a567941489ecfab4f03d66",
    file_sha256: "6f7e339af33bde250a4d7360a3176ab1ffe4e99c00eef0d71b4c322364c595f3",
    archive_entry: Some("SymbolsNerdFontMono-Regular.ttf"),
};

pub(crate) fn ensure_fonts_available(
    config: &Config,
    needs: &FontFallbackNeeds,
    script_plan: &ScriptFontPlan,
) -> Result<()> {
    if !auto_download_enabled(config) {
        download_log(DownloadLogLevel::Debug, "auto-download disabled");
        return Ok(());
    }
    let force_update = force_update_enabled(config);
    if !needs.needs_nf && !needs.needs_cjk && !needs.needs_emoji && script_plan.downloads.is_empty()
    {
        download_log(DownloadLogLevel::Debug, "no font downloads required");
        return Ok(());
    }
    let font_dirs = resolve_font_dirs(config)?;
    let Some(primary_dir) = font_dirs.first() else {
        return Ok(());
    };
    let app_families = load_app_font_families(config).unwrap_or_default();
    let allow_system = !matches!(config.font.system_fallback, FontSystemFallback::Never);
    let system_families = if allow_system {
        load_system_font_families()
    } else {
        HashSet::new()
    };

    fs::create_dir_all(primary_dir)?;

    for download in &script_plan.downloads {
        let app_has = app_families.contains(&family_key(&download.family));
        let system_has = allow_system && system_families.contains(&family_key(&download.family));
        if system_has && !app_has {
            continue;
        }
        if app_has {
            if !force_update {
                continue;
            }
            let target = primary_dir.join(&download.filename);
            if !target.exists() {
                continue;
            }
        }
        match download_notofonts_file(download, primary_dir, force_update) {
            Ok(true) => download_log(
                DownloadLogLevel::Info,
                format!("downloaded font {}", download.family),
            ),
            Ok(false) => download_log(
                DownloadLogLevel::Debug,
                format!("font up-to-date {}", download.family),
            ),
            Err(err) => download_log(
                DownloadLogLevel::Warn,
                format!("font download failed for {}: {}", download.family, err),
            ),
        }
    }

    if needs.needs_nf
        && !any_family_present(&[FONT_PACKAGE_NF.family], &app_families)
        && !(allow_system && any_family_present(&[FONT_PACKAGE_NF.family], &system_families))
    {
        match download_font_package(&FONT_PACKAGE_NF, primary_dir) {
            Ok(true) => download_log(
                DownloadLogLevel::Info,
                format!("downloaded font {}", FONT_PACKAGE_NF.family),
            ),
            Ok(false) => download_log(
                DownloadLogLevel::Debug,
                format!("font up-to-date {}", FONT_PACKAGE_NF.family),
            ),
            Err(err) => download_log(
                DownloadLogLevel::Warn,
                format!("font download failed for {}: {}", FONT_PACKAGE_NF.id, err),
            ),
        }
    }

    if needs.needs_cjk {
        let cjk_regions = collect_cjk_regions(config, needs);
        for region in cjk_regions {
            let families = cjk_region_families(region);
            let app_has = any_family_present(families, &app_families);
            let system_has = allow_system && any_family_present(families, &system_families);
            if system_has && !app_has {
                continue;
            }
            let filename = cjk_region_filename(region);
            let target = primary_dir.join(filename);
            if app_has {
                if !force_update {
                    continue;
                }
                if !target.exists() {
                    continue;
                }
            }
            match download_raw_font(cjk_region_urls(region), primary_dir, filename, force_update) {
                Ok(true) => download_log(
                    DownloadLogLevel::Info,
                    format!("downloaded font {}", filename),
                ),
                Ok(false) => download_log(
                    DownloadLogLevel::Debug,
                    format!("font up-to-date {}", filename),
                ),
                Err(err) => download_log(
                    DownloadLogLevel::Warn,
                    format!("font download failed for cjk: {}", err),
                ),
            }
        }
    }

    if needs.needs_emoji {
        let app_has = any_family_present(AUTO_FALLBACK_EMOJI, &app_families);
        let system_has = allow_system && any_family_present(AUTO_FALLBACK_EMOJI, &system_families);
        if !system_has || app_has {
            let filename = "NotoColorEmoji.ttf";
            let target = primary_dir.join(filename);
            if !app_has || (force_update && target.exists()) {
                match download_raw_font(NOTO_EMOJI_URLS, primary_dir, filename, force_update) {
                    Ok(true) => {
                        download_log(DownloadLogLevel::Info, "downloaded font Noto Color Emoji")
                    }
                    Ok(false) => {
                        download_log(DownloadLogLevel::Debug, "font up-to-date Noto Color Emoji")
                    }
                    Err(err) => download_log(
                        DownloadLogLevel::Warn,
                        format!("font download failed for emoji: {}", err),
                    ),
                }
            }
        }
    }

    Ok(())
}

pub(crate) fn any_family_present(families: &[&str], set: &HashSet<String>) -> bool {
    families.iter().any(|name| set.contains(&family_key(name)))
}

pub(crate) fn download_raw_font(
    urls: &[&str],
    dir: &Path,
    filename: &str,
    force_update: bool,
) -> Result<bool> {
    let target = dir.join(filename);
    let mut last_error: Option<Error> = None;
    for url in urls {
        match download_url_with_etag(url, &target, force_update) {
            Ok(downloaded) => return Ok(downloaded),
            Err(err) => {
                last_error = Some(err);
                download_log(
                    DownloadLogLevel::Debug,
                    format!(
                        "download failed from {}: {}",
                        url,
                        last_error.as_ref().unwrap()
                    ),
                );
            }
        }
    }
    Err(last_error
        .unwrap_or_else(|| Error::Render("font download failed: no available urls".to_string())))
}

pub(crate) fn download_notofonts_file(
    download: &ScriptDownload,
    dir: &Path,
    force_update: bool,
) -> Result<bool> {
    let target = dir.join(&download.filename);
    if !force_update && target.exists() {
        return Ok(false);
    }
    let mut refs = vec!["main".to_string(), "master".to_string()];
    if let Some(tag) = &download.tag {
        if !tag.is_empty() {
            refs.push(tag.clone());
        }
    }
    let mut last_error: Option<Error> = None;
    for reference in refs {
        let url = format!(
            "https://raw.githubusercontent.com/{}/{}/{}",
            download.repo, reference, download.file_path
        );
        match download_url_with_etag(&url, &target, force_update) {
            Ok(downloaded) => return Ok(downloaded),
            Err(err) => {
                last_error = Some(err);
                download_log(
                    DownloadLogLevel::Debug,
                    format!(
                        "download failed from {}: {}",
                        url,
                        last_error.as_ref().unwrap()
                    ),
                );
            }
        }
    }
    Err(last_error
        .unwrap_or_else(|| Error::Render("font download failed: no available refs".to_string())))
}

pub(crate) fn download_url_to_file(url: &str, target: &Path) -> Result<()> {
    match fetch_with_candidates(url, &[])? {
        FetchOutcome::Ok(resp, _proxy) => {
            let mut reader = resp.into_reader();
            let mut file = fs::File::create(target)?;
            std::io::copy(&mut reader, &mut file)?;
            file.sync_all()?;
            Ok(())
        }
        FetchOutcome::NotModified => Ok(()),
    }
}

pub(crate) fn download_zip_with_candidates(url: &str, target: &Path) -> Result<()> {
    let candidates = build_github_candidates();
    let mut last_error: Option<String> = None;
    for proxy_opt in candidates {
        let target_url = match &proxy_opt {
            Some(proxy) => apply_github_proxy(url, proxy),
            None => url.to_string(),
        };
        download_log(
            DownloadLogLevel::Debug,
            format!(
                "fetching {} via {}",
                url,
                proxy_opt.as_deref().unwrap_or("direct")
            ),
        );
        let req = HTTP_AGENT
            .get(&target_url)
            .set("User-Agent", "cryosnap/auto-font");
        match req.call() {
            Ok(resp) => {
                let temp = target.with_extension("download");
                let mut reader = resp.into_reader();
                let mut file = fs::File::create(&temp)?;
                std::io::copy(&mut reader, &mut file)?;
                file.sync_all()?;
                if let Err(err) = validate_zip_archive(&temp) {
                    last_error = Some(err.to_string());
                    let _ = fs::remove_file(&temp);
                    continue;
                }
                fs::rename(&temp, target)?;
                return Ok(());
            }
            Err(ureq::Error::Status(status, _)) => {
                last_error = Some(format!("status {status}"));
                download_log(
                    DownloadLogLevel::Debug,
                    format!(
                        "fetch failed via {}: {}",
                        proxy_opt.as_deref().unwrap_or("direct"),
                        last_error.as_deref().unwrap_or("unknown error")
                    ),
                );
                continue;
            }
            Err(err) => {
                last_error = Some(format!("{err}"));
                download_log(
                    DownloadLogLevel::Debug,
                    format!(
                        "fetch failed via {}: {}",
                        proxy_opt.as_deref().unwrap_or("direct"),
                        last_error.as_deref().unwrap_or("unknown error")
                    ),
                );
                continue;
            }
        }
    }
    Err(Error::Render(format!(
        "download failed: {}",
        last_error.unwrap_or_else(|| "unknown error".to_string())
    )))
}

pub(crate) fn validate_zip_archive(path: &Path) -> Result<()> {
    let file = fs::File::open(path)?;
    match zip::ZipArchive::new(file) {
        Ok(_) => Ok(()),
        Err(err) => Err(Error::Render(format!("zip read: {err}"))),
    }
}

pub(crate) fn download_font_package(pkg: &FontPackage, dir: &Path) -> Result<bool> {
    let target = dir.join(pkg.filename);
    if verify_sha256(&target, pkg.file_sha256)? {
        return Ok(false);
    }
    let temp = dir.join(format!("{}.download", pkg.filename));
    if pkg.archive_entry.is_some() {
        download_zip_with_candidates(pkg.url, &temp)?;
    } else {
        download_url_to_file(pkg.url, &temp)?;
    }
    if !verify_sha256(&temp, pkg.download_sha256)? {
        let _ = fs::remove_file(&temp);
        return Err(Error::Render(format!(
            "font checksum mismatch for {}",
            pkg.id
        )));
    }
    if let Some(entry) = pkg.archive_entry {
        extract_zip_entry(&temp, entry, &target)?;
        let _ = fs::remove_file(&temp);
    } else {
        fs::rename(&temp, &target)?;
    }
    if !verify_sha256(&target, pkg.file_sha256)? {
        return Err(Error::Render(format!(
            "font checksum mismatch for {}",
            pkg.id
        )));
    }
    Ok(true)
}

pub(crate) fn extract_zip_entry(archive_path: &Path, entry: &str, target: &Path) -> Result<()> {
    let file = fs::File::open(archive_path)?;
    let mut archive =
        zip::ZipArchive::new(file).map_err(|err| Error::Render(format!("zip read: {err}")))?;
    let mut entry_file = archive
        .by_name(entry)
        .map_err(|err| Error::Render(format!("zip entry {entry}: {err}")))?;
    let mut out = fs::File::create(target)?;
    std::io::copy(&mut entry_file, &mut out)?;
    out.sync_all()?;
    Ok(())
}

pub(crate) fn verify_sha256(path: &Path, expected: &str) -> Result<bool> {
    if !path.exists() {
        return Ok(false);
    }
    if expected.trim().is_empty() {
        return Ok(true);
    }
    let actual = sha256_hex(path)?;
    Ok(actual.eq_ignore_ascii_case(expected.trim()))
}

pub(crate) fn sha256_hex(path: &Path) -> Result<String> {
    let mut file = fs::File::open(path)?;
    let mut hasher = Sha256::new();
    let mut buf = [0u8; 8192];
    loop {
        let read = file.read(&mut buf)?;
        if read == 0 {
            break;
        }
        hasher.update(&buf[..read]);
    }
    let digest = hasher.finalize();
    let mut out = String::with_capacity(digest.len() * 2);
    for byte in digest {
        out.push_str(&format!("{:02x}", byte));
    }
    Ok(out)
}

#[cfg(test)]
pub(crate) fn set_notofonts_state(
    state: Option<Arc<NotofontsState>>,
) -> Option<Arc<NotofontsState>> {
    let mut guard = NOTOFONTS_STATE.lock().expect("state lock");
    let prev = guard.clone();
    *guard = state;
    prev
}
