use anyhow::Result;
use hf_hub::{api::sync::Api, Repo, RepoType};
use image::{imageops::FilterType, ImageReader};
use ort::ep;
use ort::session::SessionInputValue;
use ort::session::SessionOutputs;
use ort::session::{builder::GraphOptimizationLevel, Session};
use ort::value::{Shape, Tensor};
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Instant;
use tokenizers::Tokenizer;

// Suppress the console window when spawning curl.exe from the GUI app.
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;

pub const FLORENCE_MODEL_ID: &str = "onnx-community/Florence-2-base-ft";
pub const FLORENCE_CAPTION_MODEL_NAME: &str = "florence-2-base-ft-onnx-q4";
const ONNX_RUNTIME_NUGET_URL: &str =
    "https://www.nuget.org/api/v2/package/Microsoft.ML.OnnxRuntime.DirectML/1.24.2";
const DIRECTML_NUGET_URL: &str =
    "https://www.nuget.org/api/v2/package/Microsoft.AI.DirectML/1.15.4";
const ONNX_RUNTIME_DLL_FILE: &str = "onnxruntime/onnxruntime.dll";
const ONNX_RUNTIME_PROVIDERS_DLL_FILE: &str = "onnxruntime/onnxruntime_providers_shared.dll";
const DIRECTML_DLL_FILE: &str = "onnxruntime/DirectML.dll";
const CAPTION_ACCELERATION_FILE: &str = "settings/caption_acceleration.txt";
const CAPTION_DETAIL_FILE: &str = "settings/caption_detail.txt";

const ONNX_RUNTIME_FILES: &[(&str, &str, &str)] = &[
    (
        ONNX_RUNTIME_DLL_FILE,
        ONNX_RUNTIME_NUGET_URL,
        "runtimes/win-x64/native/onnxruntime.dll",
    ),
    (
        ONNX_RUNTIME_PROVIDERS_DLL_FILE,
        ONNX_RUNTIME_NUGET_URL,
        "runtimes/win-x64/native/onnxruntime_providers_shared.dll",
    ),
    (
        DIRECTML_DLL_FILE,
        DIRECTML_NUGET_URL,
        "bin/x64-win/DirectML.dll",
    ),
];

const REQUIRED_FILES: &[&str] = &[
    ONNX_RUNTIME_DLL_FILE,
    ONNX_RUNTIME_PROVIDERS_DLL_FILE,
    DIRECTML_DLL_FILE,
    "config.json",
    "generation_config.json",
    "preprocessor_config.json",
    "tokenizer.json",
    "tokenizer_config.json",
    "special_tokens_map.json",
    "onnx/vision_encoder_fp16.onnx",
    "onnx/encoder_model_q4.onnx",
    "onnx/decoder_model_q4.onnx",
    "onnx/decoder_model_merged_q4.onnx",
    "onnx/embed_tokens_fp16.onnx",
];

// Mutex<bool> rather than OnceLock<Result>: a failed attempt (DLL not yet
// downloaded) must NOT be cached, or a later successful download could never
// recover within the same app session.
static ORT_RUNTIME_INIT: Mutex<bool> = Mutex::new(false);

/// Set to `true` by `set_caption_acceleration` so the caption worker loop
/// knows to drop its cached `FlorenceCaptioner` and reload with the new EP.
pub static CAPTION_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);

#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CaptionAcceleration {
    #[default]
    Auto,
    Cpu,
    Directml,
}

impl CaptionAcceleration {
    fn as_str(self) -> &'static str {
        match self {
            Self::Auto => "auto",
            Self::Cpu => "cpu",
            Self::Directml => "directml",
        }
    }
}

#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CaptionDetail {
    Short,
    Detailed,
    #[default]
    Paragraph,
}

impl CaptionDetail {
    fn as_str(self) -> &'static str {
        match self {
            Self::Short => "short",
            Self::Detailed => "detailed",
            Self::Paragraph => "paragraph",
        }
    }

    fn prompt(self) -> &'static str {
        match self {
            Self::Short => "What does the image describe?",
            Self::Detailed => "Describe in detail what is shown in the image.",
            Self::Paragraph => "Describe with a paragraph what is shown in the image.",
        }
    }

    fn max_new_tokens(self) -> usize {
        match self {
            Self::Short => 32,
            Self::Detailed => 56,
            Self::Paragraph => 96,
        }
    }
}

#[derive(Serialize)]
pub struct CaptionModelStatus {
    pub model_id: &'static str,
    pub model_name: &'static str,
    pub local_dir: String,
    pub ready: bool,
    pub missing_files: Vec<String>,
}

#[derive(Clone, Serialize)]
pub struct CaptionModelProgress {
    pub total_files: usize,
    pub completed_files: usize,
    pub current_file: Option<String>,
    pub done: bool,
}

#[derive(Serialize)]
pub struct CaptionRuntimeProbe {
    pub ready: bool,
    pub acceleration: CaptionAcceleration,
    pub detail: CaptionDetail,
    pub tokenizer_vocab_size: usize,
    pub sessions: Vec<CaptionRuntimeSessionProbe>,
}

#[derive(Serialize)]
pub struct CaptionRuntimeSessionProbe {
    pub file: &'static str,
    pub inputs: Vec<String>,
    pub outputs: Vec<String>,
}

#[derive(Serialize)]
pub struct CaptionVisionProbe {
    pub input_shape: Vec<i64>,
    pub output_shape: Vec<i64>,
    pub output_values: usize,
    pub acceleration: CaptionAcceleration,
}

#[derive(Clone)]
struct TensorData {
    shape: Vec<i64>,
    values: Vec<f32>,
}

pub struct FlorenceCaptioner {
    tokenizer: Tokenizer,
    caption_detail: CaptionDetail,
    vision_session: Session,
    embed_session: Session,
    encoder_session: Session,
    decoder_prefill_session: Session,
    decoder_session: Session,
}

pub fn model_dir(app_data_dir: &Path) -> PathBuf {
    app_data_dir.join("models").join("florence-2-base-ft")
}

pub fn caption_acceleration(app_data_dir: &Path) -> CaptionAcceleration {
    let path = app_data_dir.join(CAPTION_ACCELERATION_FILE);
    let Ok(value) = std::fs::read_to_string(path) else {
        return CaptionAcceleration::default();
    };

    match value.trim().to_ascii_lowercase().as_str() {
        "cpu" => CaptionAcceleration::Cpu,
        "directml" => CaptionAcceleration::Directml,
        _ => CaptionAcceleration::Auto,
    }
}

pub fn set_caption_acceleration(
    app_data_dir: &Path,
    acceleration: CaptionAcceleration,
) -> Result<CaptionAcceleration> {
    let path = app_data_dir.join(CAPTION_ACCELERATION_FILE);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(path, acceleration.as_str())?;
    CAPTION_SESSION_DIRTY.store(true, Ordering::Relaxed);
    Ok(acceleration)
}

pub fn caption_detail(app_data_dir: &Path) -> CaptionDetail {
    let path = app_data_dir.join(CAPTION_DETAIL_FILE);
    let Ok(value) = std::fs::read_to_string(path) else {
        return CaptionDetail::default();
    };

    match value.trim().to_ascii_lowercase().as_str() {
        "short" => CaptionDetail::Short,
        "paragraph" => CaptionDetail::Paragraph,
        _ => CaptionDetail::Detailed,
    }
}

pub fn set_caption_detail(app_data_dir: &Path, detail: CaptionDetail) -> Result<CaptionDetail> {
    let path = app_data_dir.join(CAPTION_DETAIL_FILE);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(path, detail.as_str())?;
    CAPTION_SESSION_DIRTY.store(true, Ordering::Relaxed);
    Ok(detail)
}

pub fn caption_model_status(app_data_dir: &Path) -> CaptionModelStatus {
    let local_dir = model_dir(app_data_dir);
    let missing_files = REQUIRED_FILES
        .iter()
        .filter(|file| !local_dir.join(file).exists())
        .map(|file| (*file).to_string())
        .collect::<Vec<_>>();

    CaptionModelStatus {
        model_id: FLORENCE_MODEL_ID,
        model_name: FLORENCE_CAPTION_MODEL_NAME,
        local_dir: local_dir.to_string_lossy().to_string(),
        ready: missing_files.is_empty(),
        missing_files,
    }
}

pub fn prepare_caption_model_with_progress(
    app_data_dir: &Path,
    emit_progress: impl Fn(CaptionModelProgress),
) -> Result<CaptionModelStatus> {
    let local_dir = model_dir(app_data_dir);
    std::fs::create_dir_all(&local_dir)?;

    let api = Api::new()?;
    let repo = api.repo(Repo::new(FLORENCE_MODEL_ID.to_string(), RepoType::Model));
    let mut completed_files = REQUIRED_FILES
        .iter()
        .filter(|file| local_dir.join(file).exists())
        .count();

    emit_progress(CaptionModelProgress {
        total_files: REQUIRED_FILES.len(),
        completed_files,
        current_file: None,
        done: completed_files == REQUIRED_FILES.len(),
    });

    for file in REQUIRED_FILES {
        let destination = local_dir.join(file);
        if destination.exists() {
            continue;
        }
        emit_progress(CaptionModelProgress {
            total_files: REQUIRED_FILES.len(),
            completed_files,
            current_file: Some((*file).to_string()),
            done: false,
        });
        if let Some(parent) = destination.parent() {
            std::fs::create_dir_all(parent)?;
        }
        if ONNX_RUNTIME_FILES
            .iter()
            .any(|(runtime_file, _, _)| runtime_file == file)
        {
            download_onnx_runtime_files(&local_dir)?;
            completed_files = REQUIRED_FILES
                .iter()
                .filter(|file| local_dir.join(file).exists())
                .count();
        } else {
            let cached = repo.get(file)?;
            std::fs::copy(cached, destination)?;
            completed_files += 1;
        }
        emit_progress(CaptionModelProgress {
            total_files: REQUIRED_FILES.len(),
            completed_files,
            current_file: Some((*file).to_string()),
            done: completed_files == REQUIRED_FILES.len(),
        });
    }

    emit_progress(CaptionModelProgress {
        total_files: REQUIRED_FILES.len(),
        completed_files,
        current_file: None,
        done: true,
    });

    Ok(caption_model_status(app_data_dir))
}

pub fn delete_caption_model(app_data_dir: &Path) -> Result<CaptionModelStatus> {
    let local_dir = model_dir(app_data_dir);
    if local_dir.exists() {
        std::fs::remove_dir_all(&local_dir)?;
    }
    Ok(caption_model_status(app_data_dir))
}

pub fn probe_caption_runtime(app_data_dir: &Path) -> Result<CaptionRuntimeProbe> {
    let status = caption_model_status(app_data_dir);
    if !status.ready {
        anyhow::bail!(
            "Florence-2 model is missing {} required file(s)",
            status.missing_files.len()
        );
    }

    let local_dir = model_dir(app_data_dir);
    ensure_onnx_runtime(&local_dir)?;
    let tokenizer =
        Tokenizer::from_file(local_dir.join("tokenizer.json")).map_err(anyhow::Error::msg)?;

    let acceleration = caption_acceleration(app_data_dir);

    // For the vision encoder (the only session that runs on the GPU EP) we
    // create an actual ORT session so we can verify which EP was actually
    // loaded, rather than just echoing the settings file.
    let vision_session = probe_vision_session(
        &local_dir.join("onnx/vision_encoder_fp16.onnx"),
        acceleration,
    )?;

    // The remaining entries are DLLs and CPU-only text/decoder models — probe
    // them with the lightweight file-size check as before.
    let other_files = [
        "onnxruntime/onnxruntime.dll",
        "onnxruntime/onnxruntime_providers_shared.dll",
        "onnxruntime/DirectML.dll",
        "onnx/embed_tokens_fp16.onnx",
        "onnx/encoder_model_q4.onnx",
        "onnx/decoder_model_q4.onnx",
        "onnx/decoder_model_merged_q4.onnx",
    ];
    let mut sessions = vec![vision_session];
    for file in other_files {
        sessions.push(probe_session(file, &local_dir.join(file))?);
    }

    Ok(CaptionRuntimeProbe {
        ready: true,
        acceleration,
        detail: caption_detail(app_data_dir),
        tokenizer_vocab_size: tokenizer.get_vocab_size(false),
        sessions,
    })
}

pub fn probe_caption_vision(app_data_dir: &Path, image_path: &Path) -> Result<CaptionVisionProbe> {
    let status = caption_model_status(app_data_dir);
    if !status.ready {
        anyhow::bail!(
            "Florence-2 model is missing {} required file(s)",
            status.missing_files.len()
        );
    }

    let local_dir = model_dir(app_data_dir);
    ensure_onnx_runtime(&local_dir)?;
    let pixels = preprocess_image(image_path)?;
    let input_shape = vec![1, 3, 768, 768];
    let input = Tensor::from_array(([1usize, 3, 768, 768], pixels.into_boxed_slice()))
        .map_err(|error| anyhow::anyhow!("{error}"))?;
    let acceleration = caption_acceleration(app_data_dir);
    let mut session = create_session(
        &local_dir.join("onnx/vision_encoder_fp16.onnx"),
        acceleration,
        true,
    )?;
    let outputs = session
        .run(ort::inputs! {
            "pixel_values" => input
        })
        .map_err(|error| anyhow::anyhow!("{error}"))?;
    let (output_shape, output_values) = outputs[0]
        .try_extract_tensor::<f32>()
        .map_err(|error| anyhow::anyhow!("{error}"))?;

    Ok(CaptionVisionProbe {
        input_shape,
        output_shape: output_shape.to_vec(),
        output_values: output_values.len(),
        acceleration,
    })
}

pub fn generate_caption(app_data_dir: &Path, image_path: &Path) -> Result<String> {
    let mut captioner = FlorenceCaptioner::new(app_data_dir)?;
    captioner.generate(image_path)
}

impl FlorenceCaptioner {
    pub fn new(app_data_dir: &Path) -> Result<Self> {
        let started_at = Instant::now();
        let status = caption_model_status(app_data_dir);
        if !status.ready {
            anyhow::bail!(
                "Florence-2 model is missing {} required file(s)",
                status.missing_files.len()
            );
        }

        let local_dir = model_dir(app_data_dir);
        ensure_onnx_runtime(&local_dir)?;
        let tokenizer =
            Tokenizer::from_file(local_dir.join("tokenizer.json")).map_err(anyhow::Error::msg)?;
        let caption_detail = caption_detail(app_data_dir);

        let sessions_started_at = Instant::now();
        let acceleration = caption_acceleration(app_data_dir);
        let vision_session = create_session(
            &local_dir.join("onnx/vision_encoder_fp16.onnx"),
            acceleration,
            true,
        )?;
        let embed_session = create_session(
            &local_dir.join("onnx/embed_tokens_fp16.onnx"),
            acceleration,
            false,
        )?;
        let encoder_session = create_session(
            &local_dir.join("onnx/encoder_model_q4.onnx"),
            acceleration,
            false,
        )?;
        let decoder_prefill_session = create_session(
            &local_dir.join("onnx/decoder_model_q4.onnx"),
            acceleration,
            false,
        )?;
        let decoder_session = create_session(
            &local_dir.join("onnx/decoder_model_merged_q4.onnx"),
            acceleration,
            false,
        )?;
        log::info!(
            "Florence sessions loaded in {:?} with {:?} acceleration (total init {:?})",
            sessions_started_at.elapsed(),
            acceleration,
            started_at.elapsed()
        );

        Ok(Self {
            tokenizer,
            caption_detail,
            vision_session,
            embed_session,
            encoder_session,
            decoder_prefill_session,
            decoder_session,
        })
    }

    pub fn generate(&mut self, image_path: &Path) -> Result<String> {
        let started_at = Instant::now();
        log::info!("Florence caption started: {}", image_path.display());
        let image_features = run_vision_encoder(&mut self.vision_session, image_path)?;
        log::info!("Florence vision encoder done in {:?}", started_at.elapsed());
        let prompt_ids = self
            .tokenizer
            .encode(self.caption_detail.prompt(), false)
            .map_err(anyhow::Error::msg)?
            .get_ids()
            .iter()
            .map(|id| i64::from(*id))
            .collect::<Vec<_>>();
        let prompt_embeds = run_token_embedder(&mut self.embed_session, &prompt_ids)?;
        log::info!(
            "Florence token embeddings done in {:?}",
            started_at.elapsed()
        );
        let encoder_embeds = concatenate_sequence_embeddings(&image_features, &prompt_embeds)?;
        let encoder_attention_mask = vec![1_i64; encoder_embeds.shape[1] as usize];
        let encoder_hidden_states = run_encoder(
            &mut self.encoder_session,
            &encoder_embeds,
            &encoder_attention_mask,
        )?;
        log::info!("Florence encoder done in {:?}", started_at.elapsed());

        let generated_ids = run_decoder(
            &mut self.decoder_prefill_session,
            &mut self.decoder_session,
            &mut self.embed_session,
            &encoder_hidden_states,
            &encoder_attention_mask,
            self.caption_detail.max_new_tokens(),
        )?;
        log::info!("Florence decoder done in {:?}", started_at.elapsed());

        let generated_u32 = generated_ids
            .into_iter()
            .map(|id| id as u32)
            .collect::<Vec<_>>();
        let caption = self
            .tokenizer
            .decode(&generated_u32, true)
            .map_err(anyhow::Error::msg)?
            .trim()
            .to_string();

        if caption.is_empty() {
            anyhow::bail!("Florence-2 generated an empty caption");
        }

        Ok(clean_caption(&caption))
    }
}

fn probe_session(file: &'static str, path: &Path) -> Result<CaptionRuntimeSessionProbe> {
    let metadata = std::fs::metadata(path)?;
    if metadata.len() == 0 {
        anyhow::bail!("{} is empty", path.display());
    }

    let (inputs, outputs) = match file {
        "onnx/vision_encoder_fp16.onnx" => (
            vec!["pixel_values".to_string()],
            vec!["image_features".to_string()],
        ),
        "onnx/embed_tokens_fp16.onnx" => (
            vec!["input_ids".to_string()],
            vec!["inputs_embeds".to_string()],
        ),
        "onnx/encoder_model_q4.onnx" => (
            vec!["attention_mask".to_string(), "inputs_embeds".to_string()],
            vec!["last_hidden_state".to_string()],
        ),
        "onnx/decoder_model_merged_q4.onnx" => (
            vec![
                "encoder_attention_mask".to_string(),
                "encoder_hidden_states".to_string(),
                "inputs_embeds".to_string(),
                "past_key_values".to_string(),
                "use_cache_branch".to_string(),
            ],
            vec!["logits".to_string(), "present_key_values".to_string()],
        ),
        "onnxruntime/onnxruntime.dll" => (
            vec!["OrtGetApiBase".to_string()],
            vec!["ORT DirectML 1.24.2".to_string()],
        ),
        "onnxruntime/onnxruntime_providers_shared.dll" => (
            vec!["providers".to_string()],
            vec!["shared provider bridge".to_string()],
        ),
        "onnxruntime/DirectML.dll" => (
            vec!["DirectX 12".to_string()],
            vec!["DirectML 1.15.4".to_string()],
        ),
        "onnx/decoder_model_q4.onnx" => (
            vec![
                "encoder_attention_mask".to_string(),
                "encoder_hidden_states".to_string(),
                "inputs_embeds".to_string(),
            ],
            vec!["logits".to_string(), "present_key_values".to_string()],
        ),
        _ => (Vec::new(), Vec::new()),
    };

    Ok(CaptionRuntimeSessionProbe {
        file,
        inputs,
        outputs,
    })
}

/// Create an actual ORT session for the vision encoder and return a probe
/// entry that reflects which execution provider was successfully loaded.
/// This is the only session that can legitimately run on DirectML; all
/// text/decoder sessions intentionally use CPU only.
fn probe_vision_session(
    path: &Path,
    acceleration: CaptionAcceleration,
) -> Result<CaptionRuntimeSessionProbe> {
    let metadata = std::fs::metadata(path)?;
    if metadata.len() == 0 {
        anyhow::bail!("{} is empty", path.display());
    }

    // Try to create the session with the requested acceleration.  If DirectML
    // was requested but fails we report what actually loaded.
    let loaded_acceleration = match acceleration {
        CaptionAcceleration::Cpu => {
            create_session(path, CaptionAcceleration::Cpu, false)?;
            CaptionAcceleration::Cpu
        }
        CaptionAcceleration::Auto => {
            // `fail_silently` — session will fall back to CPU if DirectML
            // is unavailable; we detect this by trying Directml explicitly.
            let directml_ok = create_session(path, CaptionAcceleration::Directml, true).is_ok();
            if directml_ok {
                CaptionAcceleration::Directml
            } else {
                create_session(path, CaptionAcceleration::Cpu, false)?;
                CaptionAcceleration::Cpu
            }
        }
        CaptionAcceleration::Directml => {
            create_session(path, CaptionAcceleration::Directml, true)?;
            CaptionAcceleration::Directml
        }
    };

    Ok(CaptionRuntimeSessionProbe {
        file: "onnx/vision_encoder_fp16.onnx",
        inputs: vec!["pixel_values".to_string()],
        outputs: vec![format!("image_features [EP: {:?}]", loaded_acceleration)],
    })
}

pub fn ensure_onnx_runtime(local_dir: &Path) -> Result<()> {
    let mut initialized = ORT_RUNTIME_INIT
        .lock()
        .map_err(|_| anyhow::anyhow!("ONNX runtime init lock poisoned"))?;
    if *initialized {
        return Ok(());
    }
    let dll_path = local_dir.join(ONNX_RUNTIME_DLL_FILE);
    if !dll_path.exists() {
        anyhow::bail!("ONNX Runtime DLL is missing: {}", dll_path.display());
    }
    ort::environment::init_from(&dll_path)
        .map_err(|error| anyhow::anyhow!(error.to_string()))?
        .with_name("phokus-florence")
        .commit();
    *initialized = true;
    Ok(())
}

/// Download any ONNX Runtime DLLs missing from `local_dir`, reporting per-file
/// byte progress as `(short_label, downloaded_bytes, total_bytes)`.
/// `total_bytes` is `None` when the server omits Content-Length. Unlike
/// `ensure_onnx_runtime` (init only), this actually provisions the files —
/// callers that can run on a clean install must call this first. The callback
/// fires per chunk; callers should throttle.
pub fn provision_onnx_runtime_with_progress(
    local_dir: &Path,
    mut on_progress: impl FnMut(&str, u64, Option<u64>),
) -> Result<()> {
    for (destination_file, source_url, archive_path) in ONNX_RUNTIME_FILES {
        let destination = local_dir.join(destination_file);
        if destination.exists() {
            continue;
        }
        // Strip the "onnxruntime/" prefix for a clean label.
        let label = destination_file
            .rsplit('/')
            .next()
            .unwrap_or(destination_file);
        download_nuget_file(
            source_url,
            archive_path,
            &destination,
            |downloaded, total| on_progress(label, downloaded, total),
        )?;
    }
    Ok(())
}

/// Number of ONNX Runtime DLLs still missing from `local_dir` (for progress
/// step counts before downloading).
pub fn missing_onnx_runtime_count(local_dir: &Path) -> usize {
    ONNX_RUNTIME_FILES
        .iter()
        .filter(|(destination_file, _, _)| !local_dir.join(destination_file).exists())
        .count()
}

fn download_onnx_runtime_files(local_dir: &Path) -> Result<()> {
    if !cfg!(target_os = "windows") {
        anyhow::bail!(
            "Florence-2 ONNX Runtime download is currently configured for Windows builds"
        );
    }

    for (destination_file, source_url, archive_path) in ONNX_RUNTIME_FILES {
        let destination = local_dir.join(destination_file);
        download_nuget_file(source_url, archive_path, &destination, |_, _| {})?;
    }

    Ok(())
}

// Give up only after this many *consecutive* curl runs that download nothing;
// a run that makes any progress resets the counter, so a large file completes
// across however many resumes it takes. Kept low so a hard stall (e.g. a
// broken VM NIC) fails in a couple of minutes — surfacing a retryable error —
// rather than locking the UI on "preparing" for many minutes.
const MAX_STALL_RETRIES: usize = 3;

/// Resiliently download `url` to `destination` using the system `curl.exe`.
///
/// ureq's read timeout does not fire on a stalled large transfer on Windows
/// (schannel doesn't honor the socket read timeout), so a stall there hangs
/// forever. curl detects an inactivity stall (`--speed-time`), resumes from
/// the partial file (`-C -`), and retries internally — the same behavior a
/// browser gets. We monitor the `.part` file's size for the progress bar and
/// wrap curl in an outer progress-aware retry as a backstop. The partial file
/// survives an app restart, so a later retry continues from disk.
pub fn download_file_resilient(
    url: &str,
    destination: &Path,
    mut on_progress: impl FnMut(u64, Option<u64>),
) -> Result<()> {
    if let Some(parent) = destination.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let part = match destination.extension() {
        Some(ext) => destination.with_extension(format!("{}.part", ext.to_string_lossy())),
        None => destination.with_extension("part"),
    };
    let name = destination
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| url.to_string());

    log::info!("{name}: resolving download size");
    let total = remote_content_length(url);

    // Reconcile any existing `.part` against the real size: exactly complete →
    // finish; oversized (stale/corrupt) → discard so curl restarts cleanly
    // (otherwise `curl -C -` would 416 forever).
    if let Some(total) = total {
        let size = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
        if size == total {
            std::fs::rename(&part, destination)?;
            return Ok(());
        }
        if size > total {
            let _ = std::fs::remove_file(&part);
        }
    }
    log::info!(
        "{name}: downloading via curl ({} bytes)",
        total
            .map(|t| t.to_string())
            .unwrap_or_else(|| "unknown size".into())
    );

    let mut stalls = 0usize;
    loop {
        let before = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
        match run_curl_download(url, &part, total, &mut on_progress) {
            Ok(()) => break,
            Err(error) => {
                let after = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
                if after > before {
                    log::warn!("{name}: curl interrupted at {after} bytes, resuming: {error}");
                    stalls = 0;
                } else {
                    stalls += 1;
                    log::warn!(
                        "{name}: curl made no progress ({stalls}/{MAX_STALL_RETRIES}): {error}"
                    );
                    if stalls >= MAX_STALL_RETRIES {
                        // Discard the partial so a future attempt restarts clean
                        // rather than getting stuck re-resuming a bad file.
                        let _ = std::fs::remove_file(&part);
                        return Err(error);
                    }
                }
                std::thread::sleep(std::time::Duration::from_secs(2));
            }
        }
    }

    if let Some(total) = total {
        let got = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
        if got < total {
            anyhow::bail!("{name}: incomplete after curl ({got}/{total} bytes)");
        }
    }
    std::fs::rename(&part, destination)?;
    Ok(())
}

/// Size probe via `curl -r 0-0` (a 1-byte Range request), parsing the total
/// from the `Content-Range: bytes 0-0/<total>` header. Uses curl rather than
/// ureq so no part of the download path depends on ureq (which hangs on this
/// VM's TLS stack). Returns None if the server doesn't report a size.
fn remote_content_length(url: &str) -> Option<u64> {
    let mut command = Command::new("curl.exe");
    command.args([
        "-sL",
        "-r",
        "0-0",
        "-D",
        "-",
        "-o",
        "NUL",
        "--connect-timeout",
        "30",
        "--max-time",
        "30",
        url,
    ]);
    #[cfg(target_os = "windows")]
    command.creation_flags(CREATE_NO_WINDOW);
    let output = command.output().ok()?;
    let headers = String::from_utf8_lossy(&output.stdout);
    for line in headers.lines() {
        if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-range:") {
            if let Some(total) = rest.rsplit('/').next().map(str::trim) {
                if let Ok(n) = total.parse::<u64>() {
                    return Some(n);
                }
            }
        }
    }
    None
}

/// Run one `curl.exe` download to `dest`, resuming from any partial file, while
/// reporting progress from the growing file size. Returns an error (leaving the
/// partial in place) if curl exits non-zero.
fn run_curl_download(
    url: &str,
    dest: &Path,
    total: Option<u64>,
    on_progress: &mut impl FnMut(u64, Option<u64>),
) -> Result<()> {
    let mut command = Command::new("curl.exe");
    command
        .arg("-fSL") // fail on HTTP errors, follow redirects, show errors
        .args(["-C", "-"]) // resume from the existing output file
        .args(["--retry", "3", "--retry-delay", "1", "--retry-connrefused"])
        .args(["--connect-timeout", "30"])
        // Abort (then --retry resumes) if under 1 KB/s for 30s — a real
        // inactivity timeout, which is what ureq couldn't deliver here.
        .args(["--speed-limit", "1024", "--speed-time", "30"])
        .arg("-s") // no progress meter (we watch the file instead)
        .arg("-o")
        .arg(dest)
        .arg(url)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::piped());
    #[cfg(target_os = "windows")]
    command.creation_flags(CREATE_NO_WINDOW);

    let mut child = command
        .spawn()
        .map_err(|e| anyhow::anyhow!("failed to launch curl.exe (required for downloads): {e}"))?;

    loop {
        if let Some(status) = child.try_wait()? {
            if status.success() {
                return Ok(());
            }
            let mut stderr = String::new();
            if let Some(mut pipe) = child.stderr.take() {
                let _ = pipe.read_to_string(&mut stderr);
            }
            anyhow::bail!(
                "curl exited with {}: {}",
                status
                    .code()
                    .map(|c| c.to_string())
                    .unwrap_or_else(|| "signal".into()),
                stderr.trim()
            );
        }
        let downloaded = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
        on_progress(downloaded, total);
        std::thread::sleep(std::time::Duration::from_millis(300));
    }
}

fn download_nuget_file(
    source_url: &str,
    archive_path: &str,
    destination: &Path,
    on_progress: impl FnMut(u64, Option<u64>),
) -> Result<()> {
    // Download the .nupkg (a zip) resiliently, then extract the one DLL.
    let package = destination.with_extension("nupkg");
    download_file_resilient(source_url, &package, on_progress)?;

    log::info!("extracting {archive_path} from package");
    let file = std::fs::File::open(&package)?;
    let mut archive = zip::ZipArchive::new(file)?;
    let mut dll = archive.by_name(archive_path)?;
    let temp_destination = destination.with_extension("tmp");
    {
        let mut out = std::fs::File::create(&temp_destination)?;
        std::io::copy(&mut dll, &mut out)?;
    }
    std::fs::rename(&temp_destination, destination)?;
    let _ = std::fs::remove_file(&package);
    log::info!("extracted {archive_path}");
    Ok(())
}

fn run_vision_encoder(session: &mut Session, image_path: &Path) -> Result<TensorData> {
    let pixels = preprocess_image(image_path)?;
    let input = Tensor::from_array(([1usize, 3, 768, 768], pixels.into_boxed_slice()))
        .map_err(|error| anyhow::anyhow!("{error}"))?;
    let outputs = session
        .run(ort::inputs! {
            "pixel_values" => input
        })
        .map_err(|error| anyhow::anyhow!("{error}"))?;
    tensor_data(&outputs[0])
}

fn run_token_embedder(session: &mut Session, token_ids: &[i64]) -> Result<TensorData> {
    let input_ids = Tensor::from_array((
        [1usize, token_ids.len()],
        token_ids.to_vec().into_boxed_slice(),
    ))
    .map_err(|error| anyhow::anyhow!("{error}"))?;
    let outputs = session
        .run(ort::inputs! {
            "input_ids" => input_ids
        })
        .map_err(|error| anyhow::anyhow!("{error}"))?;
    tensor_data(&outputs[0])
}

fn run_encoder(
    session: &mut Session,
    inputs_embeds: &TensorData,
    attention_mask: &[i64],
) -> Result<TensorData> {
    let attention_mask_tensor = Tensor::from_array((
        [1usize, attention_mask.len()],
        attention_mask.to_vec().into_boxed_slice(),
    ))
    .map_err(|error| anyhow::anyhow!("{error}"))?;
    let inputs_embeds_tensor = tensor_from_data(inputs_embeds)?;
    let outputs = session
        .run(ort::inputs! {
            "attention_mask" => attention_mask_tensor,
            "inputs_embeds" => inputs_embeds_tensor
        })
        .map_err(|error| anyhow::anyhow!("{error}"))?;
    tensor_data(&outputs[0])
}

fn run_decoder(
    decoder_prefill_session: &mut Session,
    decoder_session: &mut Session,
    embed_session: &mut Session,
    encoder_hidden_states: &TensorData,
    encoder_attention_mask: &[i64],
    max_new_tokens: usize,
) -> Result<Vec<i64>> {
    const DECODER_LAYERS: usize = 6;
    const DECODER_START_TOKEN_ID: i64 = 2;
    const FORCED_BOS_TOKEN_ID: i64 = 0;
    const EOS_TOKEN_ID: i64 = 2;

    let mut generated = Vec::new();
    let encoder_attention_mask_tensor = Tensor::from_array((
        [1usize, encoder_attention_mask.len()],
        encoder_attention_mask.to_vec().into_boxed_slice(),
    ))
    .map_err(|error| anyhow::anyhow!("{error}"))?;
    let encoder_hidden_states_tensor = tensor_from_data(encoder_hidden_states)?;
    let prefill_inputs_embeds = run_token_embedder(embed_session, &[DECODER_START_TOKEN_ID])?;
    let prefill_inputs_embeds_tensor = tensor_from_data(&prefill_inputs_embeds)?;
    let prefill_started_at = Instant::now();
    let prefill_outputs = decoder_prefill_session
        .run(ort::inputs! {
            "encoder_attention_mask" => encoder_attention_mask_tensor,
            "encoder_hidden_states" => encoder_hidden_states_tensor,
            "inputs_embeds" => prefill_inputs_embeds_tensor
        })
        .map_err(|error| anyhow::anyhow!("{error}"))?;
    log::info!(
        "Florence decoder prefill done in {:?}",
        prefill_started_at.elapsed()
    );
    let _prefill_logits = tensor_data(&prefill_outputs[0])?;
    let mut next_input_id = FORCED_BOS_TOKEN_ID;
    let encoder_kv = collect_present_key_values(&prefill_outputs, DECODER_LAYERS)?;
    let mut decoder_kv = encoder_kv.clone();

    for _ in 0..max_new_tokens {
        if next_input_id == EOS_TOKEN_ID {
            break;
        }
        generated.push(next_input_id);

        let inputs_embeds = run_token_embedder(embed_session, &[next_input_id])?;
        let encoder_attention_mask_tensor = Tensor::from_array((
            [1usize, encoder_attention_mask.len()],
            encoder_attention_mask.to_vec().into_boxed_slice(),
        ))
        .map_err(|error| anyhow::anyhow!("{error}"))?;
        let encoder_hidden_states_tensor = tensor_from_data(encoder_hidden_states)?;
        let inputs_embeds_tensor = tensor_from_data(&inputs_embeds)?;
        let use_cache_branch_tensor = Tensor::from_array(([1usize], vec![true].into_boxed_slice()))
            .map_err(|error| anyhow::anyhow!("{error}"))?;

        let mut inputs = ort::inputs! {
            "encoder_attention_mask" => encoder_attention_mask_tensor,
            "encoder_hidden_states" => encoder_hidden_states_tensor,
            "inputs_embeds" => inputs_embeds_tensor,
            "use_cache_branch" => use_cache_branch_tensor
        };

        for layer in 0..DECODER_LAYERS {
            push_tensor_input(
                &mut inputs,
                format!("past_key_values.{layer}.decoder.key"),
                decoder_kv[layer * 4].clone(),
            )?;
            push_tensor_input(
                &mut inputs,
                format!("past_key_values.{layer}.decoder.value"),
                decoder_kv[layer * 4 + 1].clone(),
            )?;
            push_tensor_input(
                &mut inputs,
                format!("past_key_values.{layer}.encoder.key"),
                encoder_kv[layer * 4 + 2].clone(),
            )?;
            push_tensor_input(
                &mut inputs,
                format!("past_key_values.{layer}.encoder.value"),
                encoder_kv[layer * 4 + 3].clone(),
            )?;
        }

        let outputs = decoder_session
            .run(inputs)
            .map_err(|error| anyhow::anyhow!("{error}"))?;
        let logits = tensor_data(&outputs["logits"])?;
        next_input_id = argmax_last_token(&logits)?;
        decoder_kv = collect_present_key_values(&outputs, DECODER_LAYERS)?;
    }

    log::info!("Florence decoder produced {} token(s)", generated.len());
    Ok(generated)
}

fn create_session(
    path: &Path,
    acceleration: CaptionAcceleration,
    allow_directml: bool,
) -> Result<Session> {
    let builder = Session::builder().map_err(|error| anyhow::anyhow!("{error}"))?;
    let builder = builder
        .with_optimization_level(GraphOptimizationLevel::Level3)
        .map_err(|error| anyhow::anyhow!("{error}"))?;
    let use_directml = allow_directml
        && matches!(
            acceleration,
            CaptionAcceleration::Auto | CaptionAcceleration::Directml
        );
    let builder = builder
        .with_memory_pattern(!use_directml)
        .map_err(|error| anyhow::anyhow!("{error}"))?;
    let builder = builder
        .with_parallel_execution(false)
        .map_err(|error| anyhow::anyhow!("{error}"))?;
    let builder = builder
        .with_intra_threads(1)
        .map_err(|error| anyhow::anyhow!("{error}"))?;
    let mut builder = match acceleration {
        CaptionAcceleration::Cpu => builder,
        CaptionAcceleration::Auto if use_directml => builder
            .with_execution_providers([ep::DirectML::default().build().fail_silently()])
            .unwrap_or_else(|error| error.recover()),
        CaptionAcceleration::Directml if use_directml => builder
            .with_execution_providers([ep::DirectML::default().build().error_on_failure()])
            .map_err(|error| anyhow::anyhow!("{error}"))?,
        CaptionAcceleration::Auto | CaptionAcceleration::Directml => {
            // `allow_directml` is false for the 4 text/decoder sessions —
            // they intentionally run on CPU regardless of the setting.
            log::info!(
                "Florence: using CPU for {} (DirectML disabled for this session type)",
                path.display()
            );
            builder
        }
    };
    let session = builder
        .commit_from_file(path)
        .map_err(|error| anyhow::anyhow!("{error}"))?;
    Ok(session)
}

fn concatenate_sequence_embeddings(text: &TensorData, image: &TensorData) -> Result<TensorData> {
    if text.shape.len() != 3 || image.shape.len() != 3 {
        anyhow::bail!("Expected 3D text and image embeddings");
    }
    if text.shape[0] != image.shape[0] || text.shape[2] != image.shape[2] {
        anyhow::bail!("Text and image embedding dimensions do not match");
    }

    let text_tokens = text.shape[1] as usize;
    let image_tokens = image.shape[1] as usize;
    let dim = text.shape[2] as usize;
    let mut values = Vec::with_capacity((text_tokens + image_tokens) * dim);
    values.extend_from_slice(&text.values);
    values.extend_from_slice(&image.values);

    Ok(TensorData {
        shape: vec![text.shape[0], text.shape[1] + image.shape[1], text.shape[2]],
        values,
    })
}

fn collect_present_key_values(
    outputs: &SessionOutputs<'_>,
    layers: usize,
) -> Result<Vec<TensorData>> {
    let expected = layers * 4;
    if outputs.len() < expected + 1 {
        anyhow::bail!(
            "Decoder returned {} output(s), expected at least {}",
            outputs.len(),
            expected + 1
        );
    }

    (1..=expected)
        .map(|index| tensor_data(&outputs[index]))
        .collect::<Result<Vec<_>>>()
}

fn tensor_data(value: &ort::value::DynValue) -> Result<TensorData> {
    let (shape, values) = value
        .try_extract_tensor::<f32>()
        .map_err(|error| anyhow::anyhow!("{error}"))?;
    Ok(TensorData {
        shape: shape.to_vec(),
        values: values.to_vec(),
    })
}

fn tensor_from_data(data: &TensorData) -> Result<Tensor<f32>> {
    Tensor::from_array((
        Shape::new(data.shape.clone()),
        data.values.clone().into_boxed_slice(),
    ))
    .map_err(|error| anyhow::anyhow!("{error}"))
}

fn push_tensor_input(
    inputs: &mut Vec<(Cow<'_, str>, SessionInputValue<'_>)>,
    name: String,
    data: TensorData,
) -> Result<()> {
    inputs.push((Cow::Owned(name), tensor_from_data(&data)?.into()));
    Ok(())
}

fn argmax_last_token(logits: &TensorData) -> Result<i64> {
    if logits.shape.len() != 3 {
        anyhow::bail!("Expected decoder logits to be 3D");
    }
    let sequence_length = logits.shape[1] as usize;
    let vocab_size = logits.shape[2] as usize;
    if sequence_length == 0 || vocab_size == 0 {
        anyhow::bail!("Decoder logits are empty");
    }
    let start = (sequence_length - 1) * vocab_size;
    let (index, _) = logits.values[start..start + vocab_size]
        .iter()
        .enumerate()
        .max_by(|(_, a), (_, b)| a.total_cmp(b))
        .ok_or_else(|| anyhow::anyhow!("Decoder logits are empty"))?;
    Ok(index as i64)
}

fn clean_caption(caption: &str) -> String {
    caption
        .trim()
        .trim_matches(|ch| matches!(ch, '<' | '>' | '|' | ' '))
        .to_string()
}

fn preprocess_image(image_path: &Path) -> Result<Vec<f32>> {
    let image = ImageReader::open(image_path)?.decode()?.to_rgb8();
    let resized = image::imageops::resize(&image, 768, 768, FilterType::CatmullRom);
    let mut pixel_values = vec![0.0f32; 3 * 768 * 768];
    let mean = [0.485f32, 0.456, 0.406];
    let std = [0.229f32, 0.224, 0.225];

    for (x, y, pixel) in resized.enumerate_pixels() {
        let x = x as usize;
        let y = y as usize;
        let base = y * 768 + x;
        for channel in 0..3 {
            let value = f32::from(pixel[channel]) / 255.0;
            pixel_values[channel * 768 * 768 + base] = (value - mean[channel]) / std[channel];
        }
    }

    Ok(pixel_values)
}
