use crate::app_config::{load_app_config, persist_local_deck_sync_state};
use crate::app_state::AppState;
use crate::deck_bundle::{
    DeckBundleProgress, DeckBundleRestoreProgress, DeckBundleRestoreProgressPhase,
    inspect_deck_bundle_restore, restore_deck_bundle_from_preview_with_progress,
    retarget_deck_bundle_restore_preview, write_deck_bundle_with_progress,
    write_deck_bundle_with_review_database_with_progress,
};
use crate::file_ops::{create_unique_temp_path, path_io_error};
use crate::localization::Localizer;
use cosimo::{
    DeckVisibility, HttpSyncClient, HttpSyncClientError, LocalDeckSyncState, LocalDeviceIdentity,
    SyncBundlePayload, SyncDownloadKind, SyncUploadError, SyncUploadRequest, SyncUuid,
    parse_deck_file, sync_bundle_hash,
};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread::{self, JoinHandle};
use wxdragon::id::{ID_NONE, ID_OK};
use wxdragon::prelude::*;

const SYNC_PROGRESS_DIALOG_RANGE: i32 = 1000;
const SYNC_BUNDLE_PROGRESS_START: i32 = 100;
const SYNC_BUNDLE_PROGRESS_END: i32 = 300;
const SYNC_UPLOAD_PROGRESS_START: i32 = 300;
const SYNC_DOWNLOAD_PROGRESS_START: i32 = 100;
const SYNC_TRANSFER_PROGRESS_END: i32 = 750;
const SYNC_RESTORE_EXTRACT_PROGRESS_START: i32 = 760;
const SYNC_RESTORE_EXTRACT_PROGRESS_END: i32 = 820;
const SYNC_RESTORE_BACKUP_PROGRESS_START: i32 = 820;
const SYNC_RESTORE_BACKUP_PROGRESS_END: i32 = 875;
const SYNC_RESTORE_INSTALL_PROGRESS_START: i32 = 875;
const SYNC_RESTORE_INSTALL_PROGRESS_END: i32 = 930;
const SYNC_SAVE_PROGRESS_VALUE: i32 = 950;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SyncUploadBundleKind {
    ContentOnly,
    OwnerStudyHistory,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SyncUploadSummary {
    pub(crate) deck_uuid: SyncUuid,
    pub(crate) revision: String,
    pub(crate) uploaded_bundle_hash: String,
    pub(crate) content_bundle_hash: Option<String>,
    pub(crate) sync_state: LocalDeckSyncState,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SyncUploadExpectedRevision {
    FromLocalState,
    UseRemoteRevision(Option<String>),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SyncDownloadSummary {
    pub(crate) deck_uuid: SyncUuid,
    pub(crate) revision: String,
    pub(crate) downloaded_bundle_hash: String,
    pub(crate) restored_deck_path: PathBuf,
    pub(crate) has_review_database: bool,
    pub(crate) backup_path: Option<PathBuf>,
    pub(crate) sync_state: LocalDeckSyncState,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SyncDownloadLocalTargetStatus {
    NewDeck,
    ReplacesExistingDeck(PathBuf),
}

#[derive(Debug, Clone)]
pub(crate) struct HttpSyncUploadLaunch {
    pub(crate) config_path: PathBuf,
    pub(crate) server_url: String,
    pub(crate) bearer_token: Option<String>,
    pub(crate) temp_dir: PathBuf,
    pub(crate) deck_path: PathBuf,
    pub(crate) review_db_path: PathBuf,
    pub(crate) deck_uuid: SyncUuid,
    pub(crate) owner_identity: String,
    pub(crate) device_identity: LocalDeviceIdentity,
    pub(crate) visibility: DeckVisibility,
    pub(crate) bundle_kind: SyncUploadBundleKind,
    pub(crate) expected_revision: SyncUploadExpectedRevision,
    pub(crate) localizer: Localizer,
}

#[derive(Debug, Clone)]
pub(crate) struct HttpSyncDownloadLaunch {
    pub(crate) config_path: PathBuf,
    pub(crate) server_url: String,
    pub(crate) bearer_token: Option<String>,
    pub(crate) temp_dir: PathBuf,
    pub(crate) deck_uuid: SyncUuid,
    pub(crate) download_kind: SyncDownloadKind,
    pub(crate) requester_owner_identity: Option<String>,
    pub(crate) current_deck_path: PathBuf,
    pub(crate) current_deck_uuid: Option<SyncUuid>,
    pub(crate) recent_deck_paths: Vec<PathBuf>,
    pub(crate) decks_dir: PathBuf,
    pub(crate) backup_dir: PathBuf,
    pub(crate) timestamp_unix_seconds: i64,
    pub(crate) localizer: Localizer,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SyncProgressPhase {
    Preparing,
    Bundling,
    Uploading,
    Downloading,
    Restoring,
    Saving,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SyncProgressUpdate {
    phase: SyncProgressPhase,
    value: i32,
}

impl SyncProgressUpdate {
    fn phase(phase: SyncProgressPhase) -> Self {
        Self {
            phase,
            value: sync_progress_value(phase),
        }
    }

    fn transfer(phase: SyncProgressPhase, transferred: u64, total: Option<u64>) -> Self {
        let (start, end) = sync_transfer_progress_bounds(phase);
        let Some(total) = total.filter(|total| *total > 0) else {
            return Self {
                phase,
                value: start,
            };
        };
        let transferred = transferred.min(total);
        let span = (end - start).max(0) as u64;
        let offset = transferred.saturating_mul(span) / total;
        Self {
            phase,
            value: start + offset as i32,
        }
    }

    fn bounded(
        phase: SyncProgressPhase,
        completed: usize,
        total: usize,
        start: i32,
        end: i32,
    ) -> Self {
        if total == 0 {
            return Self {
                phase,
                value: start,
            };
        }
        let completed = completed.min(total);
        let span = (end - start).max(0) as usize;
        let offset = completed.saturating_mul(span) / total;
        Self {
            phase,
            value: start + offset as i32,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SyncProgressEvent<T> {
    Progress(SyncProgressUpdate),
    Finished(Result<T, String>),
}

pub(crate) struct SyncProgressRun<T> {
    receiver: Receiver<SyncProgressEvent<T>>,
    worker: JoinHandle<()>,
}

pub(crate) fn spawn_sync_upload(
    launch: HttpSyncUploadLaunch,
) -> SyncProgressRun<SyncUploadSummary> {
    let (sender, receiver) = mpsc::channel();
    let worker = thread::spawn(move || {
        let result = upload_deck_to_http_sync_server(launch, sender.clone());
        let _ = sender.send(SyncProgressEvent::Finished(result));
    });
    SyncProgressRun { receiver, worker }
}

fn upload_deck_to_http_sync_server(
    launch: HttpSyncUploadLaunch,
    progress_sender: Sender<SyncProgressEvent<SyncUploadSummary>>,
) -> Result<SyncUploadSummary, String> {
    send_sync_progress(&progress_sender, SyncProgressPhase::Preparing);
    let previous_state = load_matching_deck_sync_state(
        &launch.config_path,
        &launch.deck_uuid,
        &launch.server_url,
        Some(&launch.owner_identity),
    )?;
    send_sync_progress(&progress_sender, SyncProgressPhase::Bundling);
    let bundle_set = create_upload_bundle_set_from_paths(
        &launch.deck_path,
        &launch.review_db_path,
        &launch.temp_dir,
        launch.bundle_kind,
        &progress_sender,
    )
    .map_err(|error| error.to_string())?;

    let request = match SyncUploadRequest::from_parts(
        launch.deck_uuid.clone(),
        &launch.owner_identity,
        launch.visibility,
        match &launch.expected_revision {
            SyncUploadExpectedRevision::FromLocalState => previous_state
                .as_ref()
                .and_then(LocalDeckSyncState::last_seen_remote_revision),
            SyncUploadExpectedRevision::UseRemoteRevision(revision) => revision.as_deref(),
        },
        launch.device_identity.clone(),
        bundle_set.content_payload.clone(),
        bundle_set.study_history_payload.clone(),
    ) {
        Ok(request) => request,
        Err(error) => {
            bundle_set.cleanup();
            return Err(error.to_string());
        }
    };
    send_sync_progress(&progress_sender, SyncProgressPhase::Uploading);
    let mut transfer_progress =
        SyncTransferProgressReporter::new(progress_sender.clone(), SyncProgressPhase::Uploading);
    let upload = http_sync_client(&launch.server_url, launch.bearer_token.as_deref())
        .and_then(|client| {
            client.upload_with_progress(request, move |transferred, total| {
                transfer_progress.report(transferred, total);
            })
        })
        .map_err(http_sync_error_text);
    bundle_set.cleanup();
    let upload = upload?;

    send_sync_progress(&progress_sender, SyncProgressPhase::Saving);
    let sync_state = LocalDeckSyncState::from_parts(
        launch.deck_uuid.clone(),
        &launch.server_url,
        Some(&launch.owner_identity),
        Some(upload.revision()),
        Some(upload.bundle_hash()),
        previous_state
            .as_ref()
            .and_then(LocalDeckSyncState::last_downloaded_remote_bundle_hash),
    )
    .map_err(|error| error.to_string())?;
    persist_local_deck_sync_state(&launch.config_path, sync_state.clone(), launch.localizer)?;

    Ok(SyncUploadSummary {
        deck_uuid: launch.deck_uuid,
        revision: upload.revision().to_string(),
        uploaded_bundle_hash: upload.bundle_hash().to_string(),
        content_bundle_hash: bundle_set
            .content_payload
            .as_ref()
            .map(|bundle| bundle.bundle_hash().to_string()),
        sync_state,
    })
}

pub(crate) fn spawn_sync_download(
    launch: HttpSyncDownloadLaunch,
) -> SyncProgressRun<SyncDownloadSummary> {
    let (sender, receiver) = mpsc::channel();
    let worker = thread::spawn(move || {
        let result = download_deck_from_http_sync_server_owned(launch, sender.clone());
        let _ = sender.send(SyncProgressEvent::Finished(result));
    });
    SyncProgressRun { receiver, worker }
}

fn download_deck_from_http_sync_server_owned(
    launch: HttpSyncDownloadLaunch,
    progress_sender: Sender<SyncProgressEvent<SyncDownloadSummary>>,
) -> Result<SyncDownloadSummary, String> {
    send_sync_progress(&progress_sender, SyncProgressPhase::Preparing);
    let previous_state = load_matching_deck_sync_state(
        &launch.config_path,
        &launch.deck_uuid,
        &launch.server_url,
        launch.requester_owner_identity.as_deref(),
    )?;
    send_sync_progress(&progress_sender, SyncProgressPhase::Downloading);
    let mut transfer_progress =
        SyncTransferProgressReporter::new(progress_sender.clone(), SyncProgressPhase::Downloading);
    let download = http_sync_client(&launch.server_url, launch.bearer_token.as_deref())
        .and_then(|client| {
            client.download_with_progress(
                &launch.deck_uuid,
                launch.download_kind,
                launch.requester_owner_identity.as_deref(),
                move |transferred, total| {
                    transfer_progress.report(transferred, total);
                },
            )
        })
        .map_err(http_sync_error_text)?;
    let downloaded_bundle_path =
        write_downloaded_bundle_to_temp(&launch.temp_dir, download.bundle())?;
    send_sync_progress(&progress_sender, SyncProgressPhase::Restoring);
    let preview = match inspect_deck_bundle_restore(&downloaded_bundle_path, &launch.decks_dir) {
        Ok(preview) => preview,
        Err(error) => {
            let _ = fs::remove_file(&downloaded_bundle_path);
            return Err(error.to_string());
        }
    };
    let target_deck_path = match sync_download_restore_target(&launch, &preview) {
        Ok(target_deck_path) => target_deck_path,
        Err(error) => {
            let _ = fs::remove_file(&downloaded_bundle_path);
            return Err(error.to_string());
        }
    };
    let restore_root = target_deck_path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."))
        .to_path_buf();
    let preview = match retarget_deck_bundle_restore_preview(&preview, &target_deck_path) {
        Ok(preview) => preview,
        Err(error) => {
            let _ = fs::remove_file(&downloaded_bundle_path);
            return Err(error.to_string());
        }
    };
    let mut restore_progress = SyncRestoreProgressReporter::new(progress_sender.clone());
    let restore_summary = restore_deck_bundle_from_preview_with_progress(
        &downloaded_bundle_path,
        &restore_root,
        &launch.backup_dir,
        launch.timestamp_unix_seconds,
        &preview,
        move |progress| restore_progress.report(progress),
    );
    let _ = fs::remove_file(&downloaded_bundle_path);
    let restore_summary = restore_summary.map_err(|error| error.to_string())?;

    send_sync_progress(&progress_sender, SyncProgressPhase::Saving);
    let sync_state = LocalDeckSyncState::from_parts(
        launch.deck_uuid.clone(),
        &launch.server_url,
        launch.requester_owner_identity.as_deref(),
        Some(download.revision()),
        previous_state
            .as_ref()
            .and_then(LocalDeckSyncState::last_uploaded_local_bundle_hash),
        Some(download.bundle().bundle_hash()),
    )
    .map_err(|error| error.to_string())?;
    persist_local_deck_sync_state(&launch.config_path, sync_state.clone(), launch.localizer)?;

    Ok(SyncDownloadSummary {
        deck_uuid: launch.deck_uuid,
        revision: download.revision().to_string(),
        downloaded_bundle_hash: download.bundle().bundle_hash().to_string(),
        restored_deck_path: restore_summary.preview.target_deck_path,
        has_review_database: restore_summary.preview.has_review_database,
        backup_path: restore_summary.backup_path,
        sync_state,
    })
}

fn send_sync_progress<T>(sender: &Sender<SyncProgressEvent<T>>, phase: SyncProgressPhase) {
    let _ = sender.send(SyncProgressEvent::Progress(SyncProgressUpdate::phase(
        phase,
    )));
}

struct SyncTransferProgressReporter<T> {
    sender: Sender<SyncProgressEvent<T>>,
    phase: SyncProgressPhase,
    last_value: i32,
}

impl<T> SyncTransferProgressReporter<T> {
    fn new(sender: Sender<SyncProgressEvent<T>>, phase: SyncProgressPhase) -> Self {
        Self {
            sender,
            phase,
            last_value: sync_progress_value(phase),
        }
    }

    fn report(&mut self, transferred: u64, total: Option<u64>) {
        let update = SyncProgressUpdate::transfer(self.phase, transferred, total);
        if update.value == self.last_value {
            return;
        }
        self.last_value = update.value;
        let _ = self.sender.send(SyncProgressEvent::Progress(update));
    }
}

struct SyncBundleProgressReporter<T> {
    sender: Sender<SyncProgressEvent<T>>,
    phase: SyncProgressPhase,
    start: i32,
    end: i32,
    last_value: i32,
}

impl<T> SyncBundleProgressReporter<T> {
    fn new(sender: Sender<SyncProgressEvent<T>>, phase: SyncProgressPhase) -> Self {
        let (start, end) = match phase {
            SyncProgressPhase::Bundling => (SYNC_BUNDLE_PROGRESS_START, SYNC_BUNDLE_PROGRESS_END),
            _ => (sync_progress_value(phase), sync_progress_value(phase)),
        };
        Self {
            sender,
            phase,
            start,
            end,
            last_value: start.saturating_sub(1),
        }
    }

    fn report(&mut self, progress: DeckBundleProgress) {
        let update = SyncProgressUpdate::bounded(
            self.phase,
            progress.completed,
            progress.total,
            self.start,
            self.end,
        );
        if update.value <= self.last_value {
            return;
        }
        self.last_value = update.value;
        let _ = self.sender.send(SyncProgressEvent::Progress(update));
    }
}

struct SyncRestoreProgressReporter<T> {
    sender: Sender<SyncProgressEvent<T>>,
    last_value: i32,
}

impl<T> SyncRestoreProgressReporter<T> {
    fn new(sender: Sender<SyncProgressEvent<T>>) -> Self {
        Self {
            sender,
            last_value: SYNC_RESTORE_EXTRACT_PROGRESS_START.saturating_sub(1),
        }
    }

    fn report(&mut self, progress: DeckBundleRestoreProgress) {
        let (start, end) = match progress.phase {
            DeckBundleRestoreProgressPhase::Extracting => (
                SYNC_RESTORE_EXTRACT_PROGRESS_START,
                SYNC_RESTORE_EXTRACT_PROGRESS_END,
            ),
            DeckBundleRestoreProgressPhase::BackingUpExistingTarget => (
                SYNC_RESTORE_BACKUP_PROGRESS_START,
                SYNC_RESTORE_BACKUP_PROGRESS_END,
            ),
            DeckBundleRestoreProgressPhase::Installing => (
                SYNC_RESTORE_INSTALL_PROGRESS_START,
                SYNC_RESTORE_INSTALL_PROGRESS_END,
            ),
        };
        let update = SyncProgressUpdate::bounded(
            SyncProgressPhase::Restoring,
            progress.completed,
            progress.total,
            start,
            end,
        );
        if update.value <= self.last_value {
            return;
        }
        self.last_value = update.value;
        let _ = self.sender.send(SyncProgressEvent::Progress(update));
    }
}

fn sync_download_restore_target(
    launch: &HttpSyncDownloadLaunch,
    preview: &crate::deck_bundle::DeckBundleRestorePreview,
) -> io::Result<PathBuf> {
    match sync_download_local_target_status(
        &launch.deck_uuid,
        &launch.current_deck_path,
        launch.current_deck_uuid.as_ref(),
        &launch.recent_deck_paths,
        &launch.decks_dir,
    )? {
        SyncDownloadLocalTargetStatus::ReplacesExistingDeck(path) => return Ok(path),
        SyncDownloadLocalTargetStatus::NewDeck => {}
    }

    match deck_uuid_from_path(&preview.target_deck_path)? {
        None if !deck_path_exists(&preview.target_deck_path)? => {
            unused_sync_download_target_path(&launch.decks_dir, &launch.deck_uuid, preview)
        }
        None => unused_sync_download_target_path(&launch.decks_dir, &launch.deck_uuid, preview),
        Some(existing_uuid) if existing_uuid == launch.deck_uuid => {
            Ok(preview.target_deck_path.clone())
        }
        Some(_) => unused_sync_download_target_path(&launch.decks_dir, &launch.deck_uuid, preview),
    }
}

pub(crate) fn sync_download_local_target_status(
    deck_uuid: &SyncUuid,
    current_deck_path: &Path,
    current_deck_uuid: Option<&SyncUuid>,
    recent_deck_paths: &[PathBuf],
    decks_dir: &Path,
) -> io::Result<SyncDownloadLocalTargetStatus> {
    if current_deck_uuid == Some(deck_uuid) {
        return Ok(SyncDownloadLocalTargetStatus::ReplacesExistingDeck(
            current_deck_path.to_path_buf(),
        ));
    }

    if let Some(path) = find_existing_local_deck_with_uuid(recent_deck_paths.iter(), deck_uuid) {
        return Ok(SyncDownloadLocalTargetStatus::ReplacesExistingDeck(path));
    }

    if let Some(path) = find_existing_deck_in_directory_with_uuid(decks_dir, deck_uuid)? {
        return Ok(SyncDownloadLocalTargetStatus::ReplacesExistingDeck(path));
    }

    Ok(SyncDownloadLocalTargetStatus::NewDeck)
}

fn find_existing_local_deck_with_uuid<'a>(
    paths: impl Iterator<Item = &'a PathBuf>,
    deck_uuid: &SyncUuid,
) -> Option<PathBuf> {
    paths
        .filter(|path| path.is_file())
        .find_map(|path| match deck_uuid_from_path(path) {
            Ok(Some(found_uuid)) if &found_uuid == deck_uuid => Some(path.clone()),
            _ => None,
        })
}

fn find_existing_deck_in_directory_with_uuid(
    decks_dir: &Path,
    deck_uuid: &SyncUuid,
) -> io::Result<Option<PathBuf>> {
    let entries = match fs::read_dir(decks_dir) {
        Ok(entries) => entries,
        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
        Err(error) => {
            return Err(path_io_error(
                "could not inspect local decks directory for sync download target",
                decks_dir,
                error,
            ));
        }
    };

    for entry in entries {
        let entry = entry.map_err(|error| {
            path_io_error(
                "could not inspect local decks directory entry for sync download target",
                decks_dir,
                error,
            )
        })?;
        let path = entry.path();
        if !path.is_file()
            || !path
                .extension()
                .and_then(|extension| extension.to_str())
                .is_some_and(|extension| extension.eq_ignore_ascii_case("cosimo-deck"))
        {
            continue;
        }
        if deck_uuid_from_path(&path)?.as_ref() == Some(deck_uuid) {
            return Ok(Some(path));
        }
    }
    Ok(None)
}

fn deck_uuid_from_path(deck_path: &Path) -> io::Result<Option<SyncUuid>> {
    if !deck_path_exists(deck_path)? {
        return Ok(None);
    }
    let deck_text = fs::read_to_string(deck_path)
        .map_err(|error| path_io_error("could not read local deck file", deck_path, error))?;
    let deck = parse_deck_file(&deck_text).map_err(|error| {
        path_io_error(
            "could not parse local deck file",
            deck_path,
            io::Error::new(io::ErrorKind::InvalidData, error.to_string()),
        )
    })?;
    Ok(deck.metadata().deck_uuid())
}

fn deck_path_exists(deck_path: &Path) -> io::Result<bool> {
    deck_path
        .try_exists()
        .map_err(|error| path_io_error("could not inspect local deck file", deck_path, error))
}

fn unused_sync_download_target_path(
    decks_dir: &Path,
    deck_uuid: &SyncUuid,
    preview: &crate::deck_bundle::DeckBundleRestorePreview,
) -> io::Result<PathBuf> {
    fs::create_dir_all(decks_dir)
        .map_err(|error| path_io_error("could not create decks directory", decks_dir, error))?;
    let sync_stem = format!("synced-{}", deck_uuid.as_str());
    for candidate in
        std::iter::once(preview.target_deck_path.clone()).chain((0..1000).map(|index| {
            let suffix = if index == 0 {
                String::new()
            } else {
                format!("-{}", index + 1)
            };
            decks_dir.join(format!("{sync_stem}{suffix}.cosimo-deck"))
        }))
    {
        if restore_target_would_overwrite_files(preview, &candidate)? {
            continue;
        }
        return Ok(candidate);
    }

    Err(io::Error::new(
        io::ErrorKind::AlreadyExists,
        format!(
            "could not find a non-overwriting local path for synced deck {}",
            deck_uuid.as_str()
        ),
    ))
}

fn restore_target_would_overwrite_files(
    preview: &crate::deck_bundle::DeckBundleRestorePreview,
    target_deck_path: &Path,
) -> io::Result<bool> {
    retarget_deck_bundle_restore_preview(preview, target_deck_path)
        .map(|preview| preview.overwrites_existing_files())
}

pub(crate) fn show_sync_progress_dialog<W, T>(
    parent: &W,
    localizer: Localizer,
    run: SyncProgressRun<T>,
) -> Result<T, String>
where
    W: WxWidget,
    T: Send + 'static,
{
    let dialog = Dialog::builder(parent, localizer.sync_progress_caption())
        .with_style(DialogStyle::Caption)
        .build();
    dialog.set_escape_id(ID_NONE);

    let status = StaticText::builder(&dialog)
        .with_label(localizer.sync_progress_preparing())
        .build();
    let gauge = Gauge::builder(&dialog)
        .with_size(Size::new(420, -1))
        .with_style(GaugeStyle::Smooth | GaugeStyle::ShowProgress)
        .with_range(SYNC_PROGRESS_DIALOG_RANGE)
        .build();
    gauge.set_name(localizer.sync_progress_caption());

    let sizer = BoxSizer::builder(Orientation::Vertical).build();
    sizer.add(&status, 0, SizerFlag::Expand | SizerFlag::All, 12);
    sizer.add(
        &gauge,
        0,
        SizerFlag::Expand | SizerFlag::Left | SizerFlag::Right | SizerFlag::Bottom,
        12,
    );
    dialog.set_sizer_and_fit(sizer, true);
    dialog.centre();

    let (result_sender, result_receiver) = mpsc::channel();
    forward_sync_progress_to_dialog(
        localizer.sync_progress_unknown_failure().to_string(),
        localizer,
        run,
        dialog,
        status,
        gauge,
        result_sender,
    );

    let _ = dialog.show_modal();
    dialog.destroy();
    result_receiver
        .try_recv()
        .unwrap_or_else(|_| Err(localizer.sync_progress_unknown_failure()))
}

fn forward_sync_progress_to_dialog<T>(
    unknown_failure: String,
    localizer: Localizer,
    run: SyncProgressRun<T>,
    dialog: Dialog,
    status: StaticText,
    gauge: Gauge,
    result_sender: Sender<Result<T, String>>,
) where
    T: Send + 'static,
{
    thread::spawn(move || {
        let mut final_result = Err(unknown_failure);
        let mut last_phase = None;
        let mut last_value = 0;

        while let Ok(event) = run.receiver.recv() {
            match event {
                SyncProgressEvent::Progress(update) => {
                    let label = if last_phase == Some(update.phase) {
                        None
                    } else {
                        last_phase = Some(update.phase);
                        Some(sync_progress_phase_text(localizer, update.phase).to_string())
                    };
                    last_value = last_value.max(update.value);
                    let value = last_value;
                    wxdragon::call_after(Box::new(move || {
                        if let Some(label) = label {
                            status.set_label(&label);
                        }
                        gauge.set_value(value);
                    }));
                }
                SyncProgressEvent::Finished(result) => {
                    final_result = result;
                    break;
                }
            }
        }

        wxdragon::call_after(Box::new(move || {
            gauge.set_value(SYNC_PROGRESS_DIALOG_RANGE);
        }));

        let _ = run.worker.join();
        let _ = result_sender.send(final_result);
        wxdragon::call_after(Box::new(move || {
            dialog.end_modal(ID_OK);
        }));
    });
}

fn sync_progress_value(phase: SyncProgressPhase) -> i32 {
    match phase {
        SyncProgressPhase::Preparing => 50,
        SyncProgressPhase::Bundling => SYNC_BUNDLE_PROGRESS_START,
        SyncProgressPhase::Uploading | SyncProgressPhase::Downloading => {
            sync_transfer_progress_bounds(phase).0
        }
        SyncProgressPhase::Restoring => SYNC_RESTORE_EXTRACT_PROGRESS_START,
        SyncProgressPhase::Saving => SYNC_SAVE_PROGRESS_VALUE,
    }
}

fn sync_transfer_progress_bounds(phase: SyncProgressPhase) -> (i32, i32) {
    match phase {
        SyncProgressPhase::Uploading => (SYNC_UPLOAD_PROGRESS_START, SYNC_TRANSFER_PROGRESS_END),
        SyncProgressPhase::Downloading => {
            (SYNC_DOWNLOAD_PROGRESS_START, SYNC_TRANSFER_PROGRESS_END)
        }
        _ => (sync_progress_value(phase), sync_progress_value(phase)),
    }
}

fn sync_progress_phase_text(localizer: Localizer, phase: SyncProgressPhase) -> &'static str {
    match phase {
        SyncProgressPhase::Preparing => localizer.sync_progress_preparing(),
        SyncProgressPhase::Bundling => localizer.sync_progress_bundling(),
        SyncProgressPhase::Uploading => localizer.sync_progress_uploading(),
        SyncProgressPhase::Downloading => localizer.sync_progress_downloading(),
        SyncProgressPhase::Restoring => localizer.sync_progress_restoring(),
        SyncProgressPhase::Saving => localizer.sync_progress_saving(),
    }
}

fn http_sync_client(
    server_url: &str,
    bearer_token: Option<&str>,
) -> Result<HttpSyncClient, HttpSyncClientError> {
    let client = HttpSyncClient::new(server_url)?;
    Ok(match bearer_token {
        Some(token) => client.with_bearer_token(token),
        None => client,
    })
}

pub(crate) fn ensure_state_deck_uuid(state: &mut AppState) -> Result<SyncUuid, String> {
    if let Some(uuid) = state.deck_metadata.deck_uuid() {
        return Ok(uuid);
    }

    let uuid = SyncUuid::new_v4().map_err(|error| error.to_string())?;
    let mut metadata = state.deck_metadata.clone();
    metadata.set_deck_uuid(&uuid);
    state.update_deck_metadata(metadata)?;
    Ok(uuid)
}

fn load_matching_deck_sync_state(
    config_path: &Path,
    deck_uuid: &SyncUuid,
    sync_server_url: &str,
    owner_identity: Option<&str>,
) -> Result<Option<LocalDeckSyncState>, String> {
    let config = load_app_config(config_path)?;
    let probe = LocalDeckSyncState::from_parts(
        deck_uuid.clone(),
        sync_server_url,
        owner_identity,
        None,
        None,
        None,
    )
    .map_err(|error| error.to_string())?;
    Ok(config
        .deck_sync_states
        .into_iter()
        .find(|state| state.same_remote_slot(&probe)))
}

struct UploadBundleSet {
    content_payload: Option<SyncBundlePayload>,
    study_history_payload: Option<SyncBundlePayload>,
    paths: Vec<PathBuf>,
}

impl UploadBundleSet {
    fn cleanup(&self) {
        for path in &self.paths {
            let _ = fs::remove_file(path);
        }
    }
}

#[cfg(test)]
fn create_upload_bundle_set(
    deck_path: &Path,
    review_db_path: &Path,
    store: &cosimo::SqliteReviewStore,
    temp_dir: &Path,
    bundle_kind: SyncUploadBundleKind,
) -> io::Result<UploadBundleSet> {
    fs::create_dir_all(temp_dir).map_err(|error| {
        path_io_error("could not create sync temporary directory", temp_dir, error)
    })?;
    let mut paths = Vec::new();
    let result: io::Result<(Option<SyncBundlePayload>, Option<SyncBundlePayload>)> =
        match bundle_kind {
            SyncUploadBundleKind::ContentOnly => {
                let content_path = unique_sync_temp_file(temp_dir, "content.zip")?;
                paths.push(content_path.clone());
                write_deck_bundle_with_progress(deck_path, &content_path, |_| {})?;
                let content_payload = read_bundle_payload(&content_path, false)?;
                Ok((Some(content_payload), None))
            }
            SyncUploadBundleKind::OwnerStudyHistory => {
                let study_history_path = unique_sync_temp_file(temp_dir, "study-history.zip")?;
                paths.push(study_history_path.clone());
                write_deck_bundle_with_review_database_with_progress(
                    deck_path,
                    review_db_path,
                    store,
                    &study_history_path,
                    |_| {},
                )?;
                let study_history_payload = read_bundle_payload(&study_history_path, true)?;
                Ok((None, Some(study_history_payload)))
            }
        };
    if result.is_err() {
        for path in &paths {
            let _ = fs::remove_file(path);
        }
    }
    let (content_payload, study_history_payload) = result?;
    Ok(UploadBundleSet {
        content_payload,
        study_history_payload,
        paths,
    })
}

fn create_upload_bundle_set_from_paths(
    deck_path: &Path,
    review_db_path: &Path,
    temp_dir: &Path,
    bundle_kind: SyncUploadBundleKind,
    progress_sender: &Sender<SyncProgressEvent<SyncUploadSummary>>,
) -> io::Result<UploadBundleSet> {
    fs::create_dir_all(temp_dir).map_err(|error| {
        path_io_error("could not create sync temporary directory", temp_dir, error)
    })?;
    let mut paths = Vec::new();
    let result: io::Result<(Option<SyncBundlePayload>, Option<SyncBundlePayload>)> =
        match bundle_kind {
            SyncUploadBundleKind::ContentOnly => {
                let content_path = unique_sync_temp_file(temp_dir, "content.zip")?;
                paths.push(content_path.clone());
                let mut bundle_progress = SyncBundleProgressReporter::new(
                    progress_sender.clone(),
                    SyncProgressPhase::Bundling,
                );
                write_deck_bundle_with_progress(deck_path, &content_path, move |progress| {
                    bundle_progress.report(progress);
                })?;
                let content_payload = read_bundle_payload(&content_path, false)?;
                Ok((Some(content_payload), None))
            }
            SyncUploadBundleKind::OwnerStudyHistory => {
                let study_history_path = unique_sync_temp_file(temp_dir, "study-history.zip")?;
                paths.push(study_history_path.clone());
                let store = cosimo::SqliteReviewStore::open(review_db_path).map_err(|error| {
                    path_io_error(
                        "could not open review database for sync bundle",
                        review_db_path,
                        io::Error::other(error.to_string()),
                    )
                })?;
                let mut bundle_progress = SyncBundleProgressReporter::new(
                    progress_sender.clone(),
                    SyncProgressPhase::Bundling,
                );
                write_deck_bundle_with_review_database_with_progress(
                    deck_path,
                    review_db_path,
                    &store,
                    &study_history_path,
                    move |progress| {
                        bundle_progress.report(progress);
                    },
                )?;
                let study_history_payload = read_bundle_payload(&study_history_path, true)?;
                Ok((None, Some(study_history_payload)))
            }
        };
    if result.is_err() {
        for path in &paths {
            let _ = fs::remove_file(path);
        }
    }
    let (content_payload, study_history_payload) = result?;
    Ok(UploadBundleSet {
        content_payload,
        study_history_payload,
        paths,
    })
}

fn write_downloaded_bundle_to_temp(
    temp_dir: &Path,
    payload: &SyncBundlePayload,
) -> Result<PathBuf, String> {
    fs::create_dir_all(temp_dir).map_err(|error| {
        path_io_error("could not create sync temporary directory", temp_dir, error).to_string()
    })?;
    let path =
        unique_sync_temp_file(temp_dir, "download.zip").map_err(|error| error.to_string())?;
    match fs::write(&path, payload.bytes()) {
        Ok(()) => Ok(path),
        Err(error) => {
            let _ = fs::remove_file(&path);
            Err(path_io_error("could not write downloaded sync bundle", &path, error).to_string())
        }
    }
}

fn unique_sync_temp_file(temp_dir: &Path, file_name: &str) -> io::Result<PathBuf> {
    create_unique_temp_path(&temp_dir.join(file_name))
}

fn read_bundle_payload(
    bundle_path: &Path,
    includes_review_database: bool,
) -> io::Result<SyncBundlePayload> {
    let bytes = fs::read(bundle_path)
        .map_err(|error| path_io_error("could not read sync bundle", bundle_path, error))?;
    let bundle_hash = sync_bundle_hash(&bytes);
    SyncBundlePayload::from_parts(bytes, &bundle_hash, includes_review_database)
        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))
}

fn http_sync_error_text(error: HttpSyncClientError) -> String {
    match error {
        HttpSyncClientError::Upload(SyncUploadError::Conflict { expected, actual }) => {
            if expected.is_none() && actual.is_some() {
                format!(
                    "sync conflict: the server already has revision {} for this deck, but this Cosimo installation has not downloaded it yet. Download Synced Deck first, then upload again if this local copy should replace it.",
                    actual.as_deref().unwrap_or("none")
                )
            } else {
                format!(
                    "sync conflict: expected remote revision {}, but the server has revision {}. Download Synced Deck first if you want to adopt the current remote state before uploading again.",
                    expected.as_deref().unwrap_or("none"),
                    actual.as_deref().unwrap_or("none")
                )
            }
        }
        HttpSyncClientError::Upload(SyncUploadError::Forbidden) => {
            "sync ownership mismatch: the server already has this deck UUID under a different sync owner. If this is test data, clear the sync server storage or use a fresh storage directory. Otherwise, log in as the account that originally uploaded the deck before replacing it.".to_string()
        }
        HttpSyncClientError::Upload(SyncUploadError::ReservedDeckIdentity) => {
            "sync ownership mismatch: this deck UUID is reserved on the sync server for another account, even though the active remote deck is not currently available. Cosimo will not claim that identity. Use Make Independent Copy in deck metadata before uploading this local copy.".to_string()
        }
        HttpSyncClientError::Upload(SyncUploadError::RetiredDeckIdentity) => {
            "sync deck identity retired: this deck UUID has been retired on the sync server, probably because the owning sync account was deleted. Cosimo will not reuse it. Use Make Independent Copy in deck metadata before uploading this deck.".to_string()
        }
        HttpSyncClientError::ServerBusy {
            message: _,
            retry_after_seconds,
        } => match retry_after_seconds {
            Some(seconds) => format!(
                "sync server is temporarily busy, probably because another upload or maintenance operation is in progress. Try again in about {seconds} seconds."
            ),
            None => {
                "sync server is temporarily busy, probably because another upload or maintenance operation is in progress. Try again later.".to_string()
            }
        },
        other => other.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::deck_bundle::{inspect_deck_bundle_restore, write_deck_bundle};
    use crate::localization::Language;
    use cosimo::{Datum, RecallFeedback, Review};
    use std::process;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn unique_temp_dir(prefix: &str) -> PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = std::env::temp_dir().join(format!(
            "cosimo-sync-workflow-{prefix}-{}-{unique}",
            process::id()
        ));
        fs::create_dir_all(&path).unwrap();
        path
    }

    fn write_test_deck(deck_path: &Path, prompt: &str, response: &str) {
        let text = cosimo::format_deck(&[Datum::new(prompt.to_string(), response.to_string())
            .unwrap()
            .with_card_id(1)])
        .unwrap();
        fs::write(deck_path, text).unwrap();
    }

    fn write_test_deck_with_uuid(deck_path: &Path, deck_uuid: &SyncUuid, prompt: &str) {
        let mut metadata = cosimo::DeckMetadata::v2();
        metadata.set_deck_uuid(deck_uuid);
        let text = cosimo::format_deck_with_metadata(
            &metadata,
            &[Datum::new(prompt.to_string(), "response".to_string())
                .unwrap()
                .with_card_id(1)],
        )
        .unwrap();
        fs::write(deck_path, text).unwrap();
    }

    fn download_launch_for_target_test(
        temp_dir: &Path,
        decks_dir: &Path,
        deck_uuid: SyncUuid,
        current_deck_path: PathBuf,
        current_deck_uuid: Option<SyncUuid>,
        recent_deck_paths: Vec<PathBuf>,
    ) -> HttpSyncDownloadLaunch {
        HttpSyncDownloadLaunch {
            config_path: temp_dir.join("config.ini"),
            server_url: "http://localhost:8080".to_string(),
            bearer_token: None,
            temp_dir: temp_dir.join("sync-temp"),
            deck_uuid,
            download_kind: SyncDownloadKind::OwnerStudyHistory,
            requester_owner_identity: Some("owner@example.test".to_string()),
            current_deck_path,
            current_deck_uuid,
            recent_deck_paths,
            decks_dir: decks_dir.to_path_buf(),
            backup_dir: temp_dir.join("backups"),
            timestamp_unix_seconds: 0,
            localizer: Localizer::new(Language::English),
        }
    }

    #[test]
    fn upload_bundle_set_can_include_study_history() {
        let temp_dir = unique_temp_dir("bundle-set");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let temp_bundle_dir = temp_dir.join("bundles");
        write_test_deck(&deck_path, "Capital of Spain", "Madrid");
        let state = AppState::load_deck(&deck_path).unwrap();
        state
            .store
            .record_review_at(
                &state.data[0],
                Review::new(cosimo::PromptGuess::ProbablyDontKnow, RecallFeedback::Good),
                1_800_000_000,
            )
            .unwrap();

        let bundle_set = create_upload_bundle_set(
            &state.deck_path,
            &state.review_db_path,
            &state.store,
            &temp_bundle_dir,
            SyncUploadBundleKind::OwnerStudyHistory,
        )
        .unwrap();

        assert!(bundle_set.content_payload.is_none());
        assert!(bundle_set.study_history_payload.is_some());
        assert!(
            bundle_set
                .study_history_payload
                .as_ref()
                .unwrap()
                .includes_review_database()
        );
        bundle_set.cleanup();
    }

    #[test]
    fn sync_download_current_deck_restores_current_open_path() {
        let temp_dir = unique_temp_dir("download-current-target");
        let decks_dir = temp_dir.join("decks");
        let source_dir = temp_dir.join("source");
        fs::create_dir_all(&decks_dir).unwrap();
        fs::create_dir_all(&source_dir).unwrap();
        let deck_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440001".parse().unwrap();
        let current_deck_path = temp_dir.join("outside").join("Renamed.cosimo-deck");
        fs::create_dir_all(current_deck_path.parent().unwrap()).unwrap();
        write_test_deck_with_uuid(&current_deck_path, &deck_uuid, "current");
        let remote_deck_path = source_dir.join("Original.cosimo-deck");
        write_test_deck_with_uuid(&remote_deck_path, &deck_uuid, "remote");
        let bundle_path = temp_dir.join("download.zip");
        write_deck_bundle(&remote_deck_path, &bundle_path).unwrap();
        let preview = inspect_deck_bundle_restore(&bundle_path, &decks_dir).unwrap();
        let launch = download_launch_for_target_test(
            &temp_dir,
            &decks_dir,
            deck_uuid.clone(),
            current_deck_path.clone(),
            Some(deck_uuid),
            Vec::new(),
        );

        let target = sync_download_restore_target(&launch, &preview).unwrap();

        assert_eq!(target, current_deck_path);
    }

    #[test]
    fn sync_download_local_target_status_reports_current_matching_deck() {
        let temp_dir = unique_temp_dir("download-status-current");
        let decks_dir = temp_dir.join("decks");
        let deck_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440001".parse().unwrap();
        let current_deck_path = temp_dir.join("Current.cosimo-deck");

        let status = sync_download_local_target_status(
            &deck_uuid,
            &current_deck_path,
            Some(&deck_uuid),
            &[],
            &decks_dir,
        )
        .unwrap();

        assert_eq!(
            status,
            SyncDownloadLocalTargetStatus::ReplacesExistingDeck(current_deck_path)
        );
    }

    #[test]
    fn sync_download_local_target_status_reports_recent_matching_deck() {
        let temp_dir = unique_temp_dir("download-status-recent");
        let decks_dir = temp_dir.join("decks");
        let current_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440001".parse().unwrap();
        let remote_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440002".parse().unwrap();
        let current_deck_path = temp_dir.join("Current.cosimo-deck");
        let recent_deck_path = temp_dir.join("other").join("Recent.cosimo-deck");
        fs::create_dir_all(recent_deck_path.parent().unwrap()).unwrap();
        write_test_deck_with_uuid(&recent_deck_path, &remote_uuid, "recent");

        let status = sync_download_local_target_status(
            &remote_uuid,
            &current_deck_path,
            Some(&current_uuid),
            std::slice::from_ref(&recent_deck_path),
            &decks_dir,
        )
        .unwrap();

        assert_eq!(
            status,
            SyncDownloadLocalTargetStatus::ReplacesExistingDeck(recent_deck_path)
        );
    }

    #[test]
    fn sync_download_local_target_status_reports_matching_deck_in_decks_dir() {
        let temp_dir = unique_temp_dir("download-status-dir");
        let decks_dir = temp_dir.join("decks");
        fs::create_dir_all(&decks_dir).unwrap();
        let current_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440001".parse().unwrap();
        let remote_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440002".parse().unwrap();
        let current_deck_path = temp_dir.join("Current.cosimo-deck");
        let matching_deck_path = decks_dir.join("Remote.cosimo-deck");
        write_test_deck_with_uuid(&matching_deck_path, &remote_uuid, "remote");

        let status = sync_download_local_target_status(
            &remote_uuid,
            &current_deck_path,
            Some(&current_uuid),
            &[],
            &decks_dir,
        )
        .unwrap();

        assert_eq!(
            status,
            SyncDownloadLocalTargetStatus::ReplacesExistingDeck(matching_deck_path)
        );
    }

    #[test]
    fn sync_download_local_target_status_reports_new_for_name_collision_with_other_uuid() {
        let temp_dir = unique_temp_dir("download-status-new");
        let decks_dir = temp_dir.join("decks");
        fs::create_dir_all(&decks_dir).unwrap();
        let current_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440001".parse().unwrap();
        let remote_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440002".parse().unwrap();
        let existing_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440003".parse().unwrap();
        let current_deck_path = temp_dir.join("Current.cosimo-deck");
        write_test_deck_with_uuid(
            &decks_dir.join("Remote.cosimo-deck"),
            &existing_uuid,
            "other",
        );

        let status = sync_download_local_target_status(
            &remote_uuid,
            &current_deck_path,
            Some(&current_uuid),
            &[],
            &decks_dir,
        )
        .unwrap();

        assert_eq!(status, SyncDownloadLocalTargetStatus::NewDeck);
    }

    #[test]
    fn sync_download_different_deck_does_not_overwrite_current_name_collision() {
        let temp_dir = unique_temp_dir("download-name-collision");
        let decks_dir = temp_dir.join("decks");
        let source_dir = temp_dir.join("source");
        fs::create_dir_all(&decks_dir).unwrap();
        fs::create_dir_all(&source_dir).unwrap();
        let current_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440001".parse().unwrap();
        let remote_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440002".parse().unwrap();
        let current_deck_path = decks_dir.join("Indonesian.cosimo-deck");
        write_test_deck_with_uuid(&current_deck_path, &current_uuid, "current");
        let remote_deck_path = source_dir.join("Indonesian.cosimo-deck");
        write_test_deck_with_uuid(&remote_deck_path, &remote_uuid, "remote");
        let bundle_path = temp_dir.join("download.zip");
        write_deck_bundle(&remote_deck_path, &bundle_path).unwrap();
        let preview = inspect_deck_bundle_restore(&bundle_path, &decks_dir).unwrap();
        let launch = download_launch_for_target_test(
            &temp_dir,
            &decks_dir,
            remote_uuid.clone(),
            current_deck_path.clone(),
            Some(current_uuid),
            Vec::new(),
        );

        let target = sync_download_restore_target(&launch, &preview).unwrap();

        assert_ne!(target, current_deck_path);
        assert_eq!(
            target,
            decks_dir.join(format!("synced-{}.cosimo-deck", remote_uuid.as_str()))
        );
    }

    #[test]
    fn sync_download_different_deck_does_not_overwrite_uuidless_name_collision() {
        let temp_dir = unique_temp_dir("download-uuidless-collision");
        let decks_dir = temp_dir.join("decks");
        let source_dir = temp_dir.join("source");
        fs::create_dir_all(&decks_dir).unwrap();
        fs::create_dir_all(&source_dir).unwrap();
        let remote_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440002".parse().unwrap();
        let current_deck_path = decks_dir.join("Indonesian.cosimo-deck");
        write_test_deck(&current_deck_path, "current", "response");
        let remote_deck_path = source_dir.join("Indonesian.cosimo-deck");
        write_test_deck_with_uuid(&remote_deck_path, &remote_uuid, "remote");
        let bundle_path = temp_dir.join("download.zip");
        write_deck_bundle(&remote_deck_path, &bundle_path).unwrap();
        let preview = inspect_deck_bundle_restore(&bundle_path, &decks_dir).unwrap();
        let launch = download_launch_for_target_test(
            &temp_dir,
            &decks_dir,
            remote_uuid.clone(),
            current_deck_path.clone(),
            None,
            Vec::new(),
        );

        let target = sync_download_restore_target(&launch, &preview).unwrap();

        assert_ne!(target, current_deck_path);
        assert_eq!(
            target,
            decks_dir.join(format!("synced-{}.cosimo-deck", remote_uuid.as_str()))
        );
    }

    #[test]
    fn sync_download_unknown_deck_does_not_overwrite_orphan_sidecar_files() {
        let temp_dir = unique_temp_dir("download-sidecar-collision");
        let decks_dir = temp_dir.join("decks");
        let source_dir = temp_dir.join("source");
        fs::create_dir_all(&decks_dir).unwrap();
        fs::create_dir_all(source_dir.join("Indonesian")).unwrap();
        fs::create_dir_all(decks_dir.join("Indonesian")).unwrap();
        let remote_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440002".parse().unwrap();
        let remote_deck_path = source_dir.join("Indonesian.cosimo-deck");
        write_test_deck_with_uuid(&remote_deck_path, &remote_uuid, "remote");
        fs::write(
            source_dir.join("Indonesian").join("1.prompt.opus"),
            "remote audio",
        )
        .unwrap();
        fs::write(
            decks_dir.join("Indonesian").join("1.prompt.opus"),
            "local audio",
        )
        .unwrap();
        let bundle_path = temp_dir.join("download.zip");
        write_deck_bundle(&remote_deck_path, &bundle_path).unwrap();
        let preview = inspect_deck_bundle_restore(&bundle_path, &decks_dir).unwrap();
        let launch = download_launch_for_target_test(
            &temp_dir,
            &decks_dir,
            remote_uuid.clone(),
            decks_dir.join("Current.cosimo-deck"),
            None,
            Vec::new(),
        );

        let target = sync_download_restore_target(&launch, &preview).unwrap();

        assert_eq!(
            target,
            decks_dir.join(format!("synced-{}.cosimo-deck", remote_uuid.as_str()))
        );
    }

    #[test]
    fn sync_download_unknown_deck_uses_distinct_generated_name_when_uuid_name_exists() {
        let temp_dir = unique_temp_dir("download-generated-name-collision");
        let decks_dir = temp_dir.join("decks");
        let source_dir = temp_dir.join("source");
        fs::create_dir_all(&decks_dir).unwrap();
        fs::create_dir_all(&source_dir).unwrap();
        let remote_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440002".parse().unwrap();
        let remote_deck_path = source_dir.join("Indonesian.cosimo-deck");
        write_test_deck_with_uuid(&remote_deck_path, &remote_uuid, "remote");
        fs::write(
            decks_dir.join("Indonesian.cosimo-deck"),
            "Existing\n---\nAnswer\n=1=\n",
        )
        .unwrap();
        let generated_name = format!("synced-{}.cosimo-deck", remote_uuid.as_str());
        fs::write(
            decks_dir.join(&generated_name),
            "Existing\n---\nAnswer\n=1=\n",
        )
        .unwrap();
        let bundle_path = temp_dir.join("download.zip");
        write_deck_bundle(&remote_deck_path, &bundle_path).unwrap();
        let preview = inspect_deck_bundle_restore(&bundle_path, &decks_dir).unwrap();
        let launch = download_launch_for_target_test(
            &temp_dir,
            &decks_dir,
            remote_uuid.clone(),
            decks_dir.join("Current.cosimo-deck"),
            None,
            Vec::new(),
        );

        let target = sync_download_restore_target(&launch, &preview).unwrap();

        assert_eq!(
            target,
            decks_dir.join(format!("synced-{}-2.cosimo-deck", remote_uuid.as_str()))
        );
    }

    #[test]
    fn sync_download_uses_existing_recent_deck_with_matching_uuid() {
        let temp_dir = unique_temp_dir("download-recent-target");
        let decks_dir = temp_dir.join("decks");
        let source_dir = temp_dir.join("source");
        fs::create_dir_all(&decks_dir).unwrap();
        fs::create_dir_all(&source_dir).unwrap();
        let current_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440001".parse().unwrap();
        let remote_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440002".parse().unwrap();
        let current_deck_path = decks_dir.join("Current.cosimo-deck");
        let recent_deck_path = temp_dir.join("other").join("Owned.cosimo-deck");
        fs::create_dir_all(recent_deck_path.parent().unwrap()).unwrap();
        write_test_deck_with_uuid(&current_deck_path, &current_uuid, "current");
        write_test_deck_with_uuid(&recent_deck_path, &remote_uuid, "recent");
        let remote_deck_path = source_dir.join("Remote.cosimo-deck");
        write_test_deck_with_uuid(&remote_deck_path, &remote_uuid, "remote");
        let bundle_path = temp_dir.join("download.zip");
        write_deck_bundle(&remote_deck_path, &bundle_path).unwrap();
        let preview = inspect_deck_bundle_restore(&bundle_path, &decks_dir).unwrap();
        let launch = download_launch_for_target_test(
            &temp_dir,
            &decks_dir,
            remote_uuid,
            current_deck_path,
            Some(current_uuid),
            vec![recent_deck_path.clone()],
        );

        let target = sync_download_restore_target(&launch, &preview).unwrap();

        assert_eq!(target, recent_deck_path);
    }

    #[test]
    fn sync_bundle_hash_is_stable_and_content_sensitive() {
        assert_eq!(sync_bundle_hash(b"abc"), sync_bundle_hash(b"abc"));
        assert_ne!(sync_bundle_hash(b"abc"), sync_bundle_hash(b"abd"));
        assert_eq!(
            sync_bundle_hash(b"abc"),
            "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
        );
    }

    #[test]
    fn upload_owner_mismatch_error_explains_likely_cause() {
        let message = http_sync_error_text(HttpSyncClientError::Upload(SyncUploadError::Forbidden));

        assert!(message.contains("ownership mismatch"));
        assert!(message.contains("deck UUID"));
        assert!(message.contains("different sync owner"));
    }

    #[test]
    fn reserved_deck_identity_error_explains_independent_copy_recovery() {
        let message = http_sync_error_text(HttpSyncClientError::Upload(
            SyncUploadError::ReservedDeckIdentity,
        ));

        assert!(message.contains("reserved"));
        assert!(message.contains("another account"));
        assert!(message.contains("Make Independent Copy"));
    }

    #[test]
    fn retired_deck_identity_error_explains_independent_copy_recovery() {
        let message = http_sync_error_text(HttpSyncClientError::Upload(
            SyncUploadError::RetiredDeckIdentity,
        ));

        assert!(message.contains("retired"));
        assert!(message.contains("Make Independent Copy"));
    }

    #[test]
    fn server_busy_error_explains_retry() {
        let message = http_sync_error_text(HttpSyncClientError::ServerBusy {
            message: "sync storage is temporarily busy".to_string(),
            retry_after_seconds: Some(15),
        });

        assert!(message.contains("temporarily busy"));
        assert!(message.contains("another upload"));
        assert!(message.contains("15 seconds"));
    }

    #[test]
    fn sync_progress_values_advance_for_upload_and_download_paths() {
        let upload_path = [
            SyncProgressUpdate::phase(SyncProgressPhase::Preparing),
            SyncProgressUpdate::phase(SyncProgressPhase::Bundling),
            SyncProgressUpdate::bounded(SyncProgressPhase::Bundling, 0, 100, 100, 300),
            SyncProgressUpdate::bounded(SyncProgressPhase::Bundling, 50, 100, 100, 300),
            SyncProgressUpdate::bounded(SyncProgressPhase::Bundling, 100, 100, 100, 300),
            SyncProgressUpdate::phase(SyncProgressPhase::Uploading),
            SyncProgressUpdate::transfer(SyncProgressPhase::Uploading, 50, Some(100)),
            SyncProgressUpdate::transfer(SyncProgressPhase::Uploading, 100, Some(100)),
            SyncProgressUpdate::phase(SyncProgressPhase::Saving),
        ];
        let download_path = [
            SyncProgressUpdate::phase(SyncProgressPhase::Preparing),
            SyncProgressUpdate::phase(SyncProgressPhase::Downloading),
            SyncProgressUpdate::transfer(SyncProgressPhase::Downloading, 50, Some(100)),
            SyncProgressUpdate::transfer(SyncProgressPhase::Downloading, 100, Some(100)),
            SyncProgressUpdate::phase(SyncProgressPhase::Restoring),
            SyncProgressUpdate::bounded(SyncProgressPhase::Restoring, 100, 100, 760, 820),
            SyncProgressUpdate::bounded(SyncProgressPhase::Restoring, 100, 100, 820, 875),
            SyncProgressUpdate::bounded(SyncProgressPhase::Restoring, 100, 100, 875, 930),
            SyncProgressUpdate::phase(SyncProgressPhase::Saving),
        ];

        for phases in [upload_path, download_path] {
            for window in phases.windows(2) {
                assert!(window[0].value <= window[1].value);
            }
        }
    }
}
