use dioxus::html::FileData;
use std::collections::HashMap;

use dioxus::prelude::*;

use crate::Route;
use crate::client::PolymodelClient;
use crate::session::SessionIdentity;
use draft::{DraftModelInput, DraftPartInput, DraftSummary, DraftThingInput};
use jacquard_common::deps::smol_str::SmolStr;
use polymodel_api::space_polymodel::library::Image;
use state::{PublishPhase, WizardStep};

pub(crate) mod draft;
pub(crate) mod draft_client;
pub(crate) mod state;

/// Common open licenses offered in the wizard. The first is the effective
/// default when none is chosen.
const LICENSE_OPTIONS: &[&str] = &[
    "CC-BY-4.0",
    "CC-BY-SA-4.0",
    "CC0-1.0",
    "CC-BY-NC-4.0",
    "CC-BY-NC-SA-4.0",
    "MIT",
    "All rights reserved",
];
const DEFAULT_LICENSE: &str = "CC-BY-4.0";

/// Bluesky CDN thumbnail URL for an uploaded image blob (mirrors the appview's
/// `blob_cdn_url`) so the wizard can preview an image right after upload.
fn image_cdn_url(did: &str, image: &Image) -> String {
    let cid = image.image.blob().r#ref.as_str();
    format!("https://cdn.bsky.app/img/feed_thumbnail/plain/{did}/{cid}@jpeg")
}

/// Build a `data:` URI from raw bytes for immediate display before CDN
/// replication completes. Uses base64 encoding.
fn data_uri_from_bytes(bytes: &[u8], mime: &str) -> SmolStr {
    use base64::Engine;
    let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
    format!("data:{mime};base64,{encoded}").into()
}

/// Transient state of a single in-flight raw upload (file or image).
#[derive(Clone, PartialEq)]
enum UploadStatus {
    Idle,
    Uploading(String),
    Error(String),
}

/// Whether the server-side draft is in sync with the in-memory composition.
#[derive(Clone, Copy, PartialEq)]
enum SaveState {
    /// Initial state: nothing saved yet, so the status reads as empty rather than
    /// falsely claiming everything is saved.
    New,
    Saved,
    Saving,
    Dirty,
    Error,
}

/// Publish route. Anonymous visitors see the sign-in callout (returning here);
/// signed-in users get the full wizard. `draft` is an optional draft id to
/// resume (`/publish?draft=<id>`).
#[allow(clippy::useless_format)]
#[component]
pub(crate) fn Publish(draft: String) -> Element {
    let session = use_context::<Signal<SessionIdentity>>();
    let identity = session.read().clone();

    // Session still resolving on first load: show a neutral loading state rather
    // than flashing the signed-out callout at users who are actually signed in.
    if matches!(identity, SessionIdentity::Unknown) {
        return rsx! {
            main { class: "publish-page",
                section { class: "publish-intro blueprint-panel", aria_label: "Publish",
                    h1 { "Publish" }
                    div { class: "skeleton-line skeleton-wide" }
                    div { class: "skeleton-line" }
                }
            }
        };
    }

    if matches!(identity, SessionIdentity::Anonymous) {
        return rsx! {
            main { class: "publish-page",
                section { class: "publish-signed-out blueprint-panel", aria_label: "Sign in",
                    h1 { "Share your models" }
                    form {
                        class: "session-control",
                        action: "/oauth/start",
                        method: "get",
                        input { r#type: "hidden", name: "return_to", value: "/publish" },
                        input {
                            r#type: "text",
                            name: "identifier",
                            placeholder: "alice.bsky.social",
                            aria_label: "ATProto handle",
                        }
                        button { class: "button button-primary", r#type: "submit", "Sign in" }
                    }
                    p { class: "product-lede", "Sign in to share your creations using your Bluesky or other Atmosphere account." }

                }
            }
        };
    }

    rsx! {
        PublishWizard { resume: draft }
    }
}

#[derive(Clone)]
struct Wizard {
    client: PolymodelClient,
    composition: Signal<DraftThingInput>,
    draft_id: Signal<Option<String>>,
    step: Signal<WizardStep>,
    phase: Signal<PublishPhase>,
    save: Signal<SaveState>,
    upload: Signal<UploadStatus>,
    image_upload: Signal<UploadStatus>,
    drafts: Signal<Vec<DraftSummary>>,
    /// Authenticated actor DID, used to build CDN preview URLs for uploaded images.
    did: Option<String>,
    /// Transient data-URI overrides for freshly-uploaded images, keyed by
    /// blobref CID. CDN URLs for just-uploaded blobs aren't available until
    /// replication completes; this shows the image immediately.
    image_data_uris: Signal<HashMap<SmolStr, SmolStr>>,
}

#[component]
fn PublishWizard(resume: String) -> Element {
    let client = use_context::<PolymodelClient>();
    let session = use_context::<Signal<SessionIdentity>>();
    let did = match &*session.read() {
        SessionIdentity::Authenticated(identity) => Some(identity.did.as_str().to_string()),
        _ => None,
    };
    // Seed a new draft with the effective default license so the control reflects
    // real state from the start (a control only fires change when its value changes).
    let composition = use_signal(|| DraftThingInput {
        license: Some(DEFAULT_LICENSE.to_string()),
        ..DraftThingInput::default()
    });
    let draft_id = use_signal(|| Option::<String>::None);
    let step = use_signal(|| WizardStep::Basics);
    let phase = use_signal(|| PublishPhase::Editing);
    let save = use_signal(|| SaveState::New);
    let upload = use_signal(|| UploadStatus::Idle);
    let image_upload = use_signal(|| UploadStatus::Idle);
    let drafts = use_signal(Vec::<DraftSummary>::new);
    let image_data_uris = use_signal(HashMap::<SmolStr, SmolStr>::new);

    let wizard = Wizard {
        client,
        composition,
        draft_id,
        step,
        phase,
        save,
        upload,
        image_upload,
        drafts,
        did,
        image_data_uris,
    };

    // Resume a draft once on mount when `?draft=<id>` is present; always load
    // the actor's existing drafts so they can be picked up as entry points.
    {
        let wizard = wizard.clone();
        let resume = resume.clone();
        use_hook(move || {
            if !resume.trim().is_empty() {
                let mut wizard = wizard.clone();
                let resume = resume.clone();
                spawn(async move {
                    match draft_client::get_draft(&wizard.client, resume.trim()).await {
                        Ok(loaded) => {
                            wizard.composition.set(loaded);
                            wizard.draft_id.set(Some(resume.trim().to_string()));
                        }
                        Err(error) => {
                            tracing::warn!(%error, "failed to resume draft");
                        }
                    }
                });
            }
            let wizard = wizard.clone();
            spawn(async move { wizard.reload_drafts().await });
        });
    }

    // Mirror the active draft id into the URL so a full reload (e.g. the dev
    // server's hot-reload websocket dropping and reconnecting) resumes the same
    // draft via `?draft=<id>` instead of dropping you on a blank `/publish`.
    // replace() avoids stacking a history entry on every autosave.
    {
        let draft_id = wizard.draft_id;
        use_effect(move || {
            if let Some(id) = draft_id.read().clone() {
                navigator().replace(Route::Publish { draft: id });
            }
        });
    }

    let current = *wizard.step.read();
    let body = match current {
        WizardStep::Basics => wizard.render_basics(),
        WizardStep::Files => wizard.render_files(),
        WizardStep::Images => wizard.render_images(),
        WizardStep::Details => wizard.render_details(),
        WizardStep::Review => wizard.render_review(),
    };

    rsx! {
        main { class: "publish-page",
            { wizard.render_header() }
            if let PublishPhase::Published { repo, rkey } = wizard.phase.read().clone() {
                { wizard.render_published(repo, rkey) }
            } else {
                { wizard.render_resume() }
                { wizard.render_stepper() }
                section { class: "wizard-body blueprint-panel", aria_label: "{current.label()}",
                    {body}
                }
                { wizard.render_nav() }
            }
        }
    }
}

impl Wizard {
    /// Create (first save) or update the server-side draft.
    async fn save_draft(self) {
        let mut save = self.save;
        save.set(SaveState::Saving);
        let payload = self.composition.read().clone();
        let existing = self.draft_id.read().clone();
        let result = match existing {
            Some(id) => draft_client::put_draft(&self.client, &id, &payload)
                .await
                .map(|_| id),
            None => draft_client::create_draft(&self.client, &payload).await,
        };
        match result {
            Ok(id) => {
                let mut draft_id = self.draft_id;
                draft_id.set(Some(id));
                save.set(SaveState::Saved);
            }
            Err(error) => {
                tracing::warn!(%error, "failed to save draft");
                save.set(SaveState::Error);
            }
        }
    }

    fn mark_dirty(&self) {
        let mut save = self.save;
        save.set(SaveState::Dirty);
    }

    fn goto(&self, target: WizardStep) {
        let mut step = self.step;
        step.set(target);
        let wizard = self.clone();
        spawn(async move { wizard.save_draft().await });
    }

    /// Refresh the actor's draft summaries for the resume list.
    async fn reload_drafts(self) {
        let mut drafts = self.drafts;
        match draft_client::list_drafts(&self.client).await {
            Ok(list) => drafts.set(list),
            Err(error) => tracing::warn!(%error, "failed to list drafts"),
        }
    }

    /// Load an existing draft into the wizard in place (without a route change).
    async fn resume_draft(self, id: String) {
        match draft_client::get_draft(&self.client, &id).await {
            Ok(loaded) => {
                let mut composition = self.composition;
                let mut draft_id = self.draft_id;
                let mut step = self.step;
                let mut save = self.save;
                composition.set(loaded);
                draft_id.set(Some(id));
                step.set(WizardStep::Basics);
                save.set(SaveState::Saved);
            }
            Err(error) => tracing::warn!(%error, "failed to resume draft"),
        }
    }

    /// Delete a draft from the resume list; clears the editor if it was active.
    async fn discard_draft(self, id: String) {
        match draft_client::delete_draft(&self.client, &id).await {
            Ok(_) => {
                if self.draft_id.read().as_deref() == Some(id.as_str()) {
                    let mut composition = self.composition;
                    let mut draft_id = self.draft_id;
                    composition.set(DraftThingInput::default());
                    draft_id.set(None);
                }
                self.clone().reload_drafts().await;
            }
            Err(error) => tracing::warn!(%error, "failed to delete draft"),
        }
    }

    /// Resume list: existing server-side drafts shown as entry points. Hidden
    /// when the actor has no other drafts than the one being edited.
    fn render_resume(&self) -> Element {
        let drafts = self.drafts.read().clone();
        let active = self.draft_id.read().clone();
        let others: Vec<DraftSummary> = drafts
            .into_iter()
            .filter(|d| active.as_deref() != Some(d.draft_id.as_str()))
            .collect();
        if others.is_empty() {
            return rsx! {};
        }
        rsx! {
            section { class: "wizard-resume blueprint-panel", aria_label: "Resume a draft",
                h2 { class: "wizard-subhead", "Resume a draft" }
                ul { class: "wizard-resume-list",
                    for summary in others {
                        {
                            let id = summary.draft_id.clone();
                            let name = summary
                                .name
                                .clone()
                                .filter(|n| !n.trim().is_empty())
                                .unwrap_or_else(|| "Untitled draft".to_string());
                            let model_count = summary.model_count;
                            let resume_w = self.clone();
                            let resume_id = id.clone();
                            let discard_w = self.clone();
                            let discard_id = id.clone();
                            rsx! {
                                li { key: "{id}", class: "wizard-resume-item",
                                    div { class: "wizard-resume-meta",
                                        span { class: "wizard-resume-name", "{name}" }
                                        span { class: "card-meta", "{model_count} model(s)" }
                                    }
                                    div { class: "button-row",
                                        button {
                                            class: "button button-secondary",
                                            onclick: move |_| {
                                                let wizard = resume_w.clone();
                                                let id = resume_id.clone();
                                                spawn(async move { wizard.resume_draft(id).await });
                                            },
                                            "Resume"
                                        }
                                        button {
                                            class: "button button-ghost",
                                            onclick: move |_| {
                                                let wizard = discard_w.clone();
                                                let id = discard_id.clone();
                                                spawn(async move { wizard.discard_draft(id).await });
                                            },
                                            "Delete"
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    fn render_header(&self) -> Element {
        let phase = self.phase.read().clone();
        let save = *self.save.read();
        let (pill_class, pill_label) = match &phase {
            PublishPhase::Published { .. } => ("status-pill", "Published"),
            PublishPhase::Publishing => ("status-pill", "Publishing…"),
            PublishPhase::Failed(_) => ("status-pill status-error", "Publish failed"),
            PublishPhase::Editing => ("status-pill status-muted", "Draft"),
        };
        let save_label = match save {
            SaveState::New => "",
            SaveState::Saved => "Saved",
            SaveState::Saving => "Saving…",
            SaveState::Dirty => "Unsaved changes",
            SaveState::Error => "Save failed",
        };
        rsx! {
            header { class: "publish-head",
                div { class: "publish-head-row",
                    h1 { "Publish a thing" }
                    span { class: "{pill_class}", "{pill_label}" }
                }
                p { class: "publish-save-state", aria_live: "polite", "{save_label}" }
                if let PublishPhase::Failed(message) = &phase {
                    p { class: "wizard-error", role: "alert", "{message}" }
                }
            }
        }
    }

    fn render_stepper(&self) -> Element {
        let composition = self.composition.read();
        let current = *self.step.read();
        let basics_done = composition.basics_complete();
        let files_done = composition.files_complete();
        drop(composition);
        rsx! {
            nav { class: "wizard-steps", aria_label: "Publish steps",
                for s in WizardStep::ALL {
                    {
                        let done = match s {
                            WizardStep::Basics => basics_done,
                            WizardStep::Files => files_done,
                            _ => false,
                        };
                        let is_active = s == current;
                        let wizard = self.clone();
                        let active_class = if is_active { "is-active" } else { "" };
                        let done_class = if done { "is-complete" } else { "" };
                        rsx! {
                            button {
                                key: "step-{s.number()}",
                                class: "tab wizard-step {active_class} {done_class}",
                                aria_current: if is_active { "step" } else { "false" },
                                onclick: move |_| wizard.goto(s),
                                span { class: "wizard-step-num", "{s.number()}" }
                                span { class: "wizard-step-label", "{s.label()}" }
                                if done {
                                    span { class: "wizard-step-check", aria_hidden: "true", "✓" }
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    fn render_nav(&self) -> Element {
        let current = *self.step.read();
        let publishing = matches!(*self.phase.read(), PublishPhase::Publishing);
        let prev = current.prev();
        let next = current.next();
        let wizard_back = self.clone();
        let wizard_next = self.clone();
        let wizard_save = self.clone();
        rsx! {
            footer { class: "wizard-nav",
                if let Some(prev) = prev {
                    button {
                        class: "button button-ghost",
                        disabled: publishing,
                        onclick: move |_| wizard_back.goto(prev),
                        "Back"
                    }
                }
                div { class: "wizard-nav-spacer" }
                button {
                    class: "button button-secondary",
                    disabled: publishing,
                    onclick: move |_| {
                        let wizard = wizard_save.clone();
                        spawn(async move { wizard.save_draft().await });
                    },
                    "Save draft"
                }
                if let Some(next) = next {
                    button {
                        class: "button button-primary",
                        disabled: publishing,
                        onclick: move |_| wizard_next.goto(next),
                        "Next: {next.label()}"
                    }
                }
            }
        }
    }

    fn render_basics(&self) -> Element {
        let composition = self.composition.read();
        let name = composition.name.clone().unwrap_or_default();
        let summary = composition.summary.clone().unwrap_or_default();
        drop(composition);

        let name_w = self.clone();
        let summary_w = self.clone();

        rsx! {
            h2 { "Basics" }
            label { class: "field",
                span { class: "field-label", "Name" }
                input {
                    class: "field-input",
                    r#type: "text",
                    value: "{name}",
                    placeholder: "e.g. Modular desk organizer",
                    oninput: move |evt| {
                        let mut c = name_w.composition;
                        c.write().name = Some(evt.value());
                        name_w.mark_dirty();
                    },
                }
            }
            label { class: "field",
                span { class: "field-label", "Summary" }
                textarea {
                    class: "field-input field-textarea",
                    value: "{summary}",
                    placeholder: "What is it?",
                    oninput: move |evt| {
                        let mut c = summary_w.composition;
                        let v = evt.value();
                        c.write().summary = if v.trim().is_empty() { None } else { Some(v) };
                        summary_w.mark_dirty();
                    },
                }
            }
        }
    }

    fn render_files(&self) -> Element {
        let composition = self.composition.read();
        let models = composition.models.clone();
        let upload = self.upload.read().clone();
        drop(composition);

        let add_model_w = self.clone();
        rsx! {
            h2 { "Files & parts" }
            p { class: "wizard-step-hint", "Add one or more models. Each model holds ordered parts; upload a geometry file for every part." }
            match &upload {
                UploadStatus::Uploading(label) => rsx! {
                    div { class: "wizard-upload-status", role: "status",
                        span { class: "wizard-progress-bar", "aria-hidden": "true" }
                        span { "Uploading {label}…" }
                    }
                },
                UploadStatus::Error(message) => rsx! {
                    p { class: "wizard-error", role: "alert", "{message}" }
                },
                UploadStatus::Idle => rsx! {},
            }
            if models.is_empty() {
                p { class: "wizard-empty", "No models yet. Add one to start uploading parts." }
            }
            for (mi, model) in models.iter().enumerate() {
                {
                    let model_name = model.name.clone().unwrap_or_default();
                    let parts = model.parts.clone();
                    let name_w = self.clone();
                    let remove_w = self.clone();
                    let upload_w = self.clone();
                    rsx! {
                        section { key: "model-{mi}", class: "wizard-model",
                            div { class: "wizard-model-head",
                                label { class: "field wizard-model-name",
                                    span { class: "field-label", "Model {mi + 1} name" }
                                    input {
                                        class: "field-input",
                                        r#type: "text",
                                        value: "{model_name}",
                                        placeholder: "e.g. Base plate",
                                        oninput: move |evt| {
                                            let mut c = name_w.composition;
                                            c.write().models[mi].name = Some(evt.value());
                                            name_w.mark_dirty();
                                        },
                                    }
                                }
                                button {
                                    class: "button button-ghost wizard-remove",
                                    onclick: move |_| {
                                        let mut c = remove_w.composition;
                                        c.write().models.remove(mi);
                                        remove_w.mark_dirty();
                                    },
                                    "Remove model"
                                }
                            }
                            if parts.is_empty() {
                                p { class: "wizard-empty", "No parts yet. Upload a file below." }
                            }
                            ul { class: "wizard-part-list",
                                for (pi, part) in parts.iter().enumerate() {
                                    {
                                        let part_name = part.name.clone().unwrap_or_default();
                                        let size = part.file.as_ref().map(|f| human_size(f.size)).unwrap_or_default();
                                        let format = part.format.clone().unwrap_or_default();
                                        let pname_w = self.clone();
                                        let premove_w = self.clone();
                                        rsx! {
                                            li { key: "part-{mi}-{pi}", class: "wizard-part",
                                                label { class: "field wizard-part-name",
                                                    span { class: "field-label sr-only", "Part name" }
                                                    input {
                                                        class: "field-input",
                                                        r#type: "text",
                                                        value: "{part_name}",
                                                        oninput: move |evt| {
                                                            let mut c = pname_w.composition;
                                                            c.write().models[mi].parts[pi].name = Some(evt.value());
                                                            pname_w.mark_dirty();
                                                        },
                                                    }
                                                }
                                                span { class: "wizard-part-meta card-meta",
                                                    if !format.is_empty() { "{format} · " }
                                                    "{size}"
                                                }
                                                button {
                                                    class: "button button-ghost wizard-remove",
                                                    onclick: move |_| {
                                                        let mut c = premove_w.composition;
                                                        c.write().models[mi].parts.remove(pi);
                                                        premove_w.mark_dirty();
                                                    },
                                                    "Remove"
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                            label { class: "button button-secondary wizard-file-button",
                                "Add a file"
                                input {
                                    class: "wizard-file-input",
                                    r#type: "file",
                                    accept: ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,model/*,application/octet-stream",
                                    onchange: move |evt| {
                                        if let Some(file) = evt.files().into_iter().next() {
                                            let wizard = upload_w.clone();
                                            spawn(async move { wizard.handle_file_upload(mi, file).await });
                                        }
                                    },
                                }
                            }
                        }
                    }
                }
            }
            button {
                class: "button button-secondary wizard-add-model",
                onclick: move |_| {
                    let mut c = add_model_w.composition;
                    c.write().models.push(DraftModelInput::default());
                    add_model_w.mark_dirty();
                },
                "Add a model"
            }
        }
    }

    async fn handle_file_upload(self, model_index: usize, file: FileData) {
        let filename = file.name();
        let mut upload = self.upload;
        upload.set(UploadStatus::Uploading(filename.clone()));
        let bytes = match file.read_bytes().await {
            Ok(bytes) => bytes.to_vec(),
            Err(_) => {
                upload.set(UploadStatus::Error(
                    "Could not read the selected file.".into(),
                ));
                return;
            }
        };
        let mime = file
            .content_type()
            .filter(|s| !s.is_empty())
            .unwrap_or_else(|| mime_from_filename(&filename).to_string());
        // Clone before staging: `stage_file` takes `bytes` by value, but the
        // worker preview request still needs a temporary Blob URL for the same bytes.
        match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone()).await {
            Ok(staged) => {
                let format = format_from_filename(&filename);
                let mut part = DraftPartInput {
                    name: Some(filename.clone()),
                    upload_id: Some(staged.upload_id.to_string()),
                    file: Some(staged.file),
                    format: format.clone(),
                    ..Default::default()
                };
                // Best-effort preview generation (wasm-only; no-op off-wasm).
                // v1 supports STL only in the publish wizard — skip preview for other formats.
                let preview = if format.as_deref() == Some("stl") {
                    let client = self.client.clone();
                    let upload_sig = self.upload;
                    let data_uris_sig = self.image_data_uris;
                    generate_preview(client, filename.clone(), bytes, upload_sig, data_uris_sig)
                        .await
                } else {
                    None
                };
                {
                    let mut composition = self.composition;
                    let mut guard = composition.write();
                    if let Some(image) = preview {
                        let parts_before_push =
                            guard.models.get(model_index).map_or(0, |m| m.parts.len());
                        maybe_seed_cover(&mut guard, model_index, parts_before_push, &image);
                        attach_preview(&mut part, image);
                    }
                    if let Some(model) = guard.models.get_mut(model_index) {
                        model.parts.push(part);
                    }
                }
                upload.set(UploadStatus::Idle);
                let wizard = self.clone();
                wizard.save_draft().await;
            }
            Err(error) => {
                upload.set(UploadStatus::Error(format!("Upload failed: {error}")));
            }
        }
    }

    fn render_images(&self) -> Element {
        let composition = self.composition.read();
        let cover = composition.cover.clone().unwrap_or_default();
        let previews = composition.previews.clone().unwrap_or_default();
        let status = self.image_upload.read().clone();
        drop(composition);

        let cover_w = self.clone();
        let preview_w = self.clone();
        rsx! {
            h2 { "Images" }
            match &status {
                UploadStatus::Uploading(label) => rsx! {
                    div { class: "wizard-upload-status", role: "status",
                        span { class: "wizard-progress-bar", "aria-hidden": "true" }
                        span { "Uploading {label}…" }
                    }
                },
                UploadStatus::Error(message) => rsx! { p { class: "wizard-error", role: "alert", "{message}" } },
                UploadStatus::Idle => rsx! {},
            }
            div { class: "wizard-image-group",
                h3 { class: "wizard-subhead", "Cover" }
                { self.render_image_list(&cover, true) }
                label { class: "button button-secondary wizard-file-button",
                    "Add cover image"
                    input {
                        class: "wizard-file-input",
                        r#type: "file",
                        accept: "image/png,image/jpeg,image/webp,image/gif",
                        onchange: move |evt| {
                            if let Some(file) = evt.files().into_iter().next() {
                                let wizard = cover_w.clone();
                                spawn(async move { wizard.handle_image_upload(true, file).await });
                            }
                        },
                    }
                }
            }
            div { class: "wizard-image-group",
                h3 { class: "wizard-subhead", "Previews" }
                { self.render_image_list(&previews, false) }
                label { class: "button button-secondary wizard-file-button",
                    "Add preview image"
                    input {
                        class: "wizard-file-input",
                        r#type: "file",
                        accept: "image/png,image/jpeg,image/webp,image/gif",
                        onchange: move |evt| {
                            if let Some(file) = evt.files().into_iter().next() {
                                let wizard = preview_w.clone();
                                spawn(async move { wizard.handle_image_upload(false, file).await });
                            }
                        },
                    }
                }
            }
        }
    }

    fn render_image_list(&self, images: &[Image], is_cover: bool) -> Element {
        if images.is_empty() {
            return rsx! { p { class: "wizard-empty", "No images yet." } };
        }
        rsx! {
            ul { class: "wizard-image-list",
                for (i, image) in images.iter().enumerate() {
                    {
                        let alt = image.alt.to_string();
                        let ratio = format!("{}×{}", image.aspect_ratio.width, image.aspect_ratio.height);
                        let cid = image.image.blob().r#ref.as_str().to_string();
                        let preview_url = self.image_data_uris.read().get(cid.as_str())
                            .map(|uri| uri.to_string())
                            .or_else(|| self.did.as_ref().map(|did| image_cdn_url(did, image)));
                        let alt_w = self.clone();
                        let remove_w = self.clone();
                        rsx! {
                            li { key: "image-{is_cover}-{i}", class: "wizard-image",
                                div { class: "wizard-image-thumb blueprint-media", aria_hidden: "true",
                                    if let Some(url) = preview_url {
                                        img { class: "wizard-image-img", src: "{url}", alt: "" }
                                    } else {
                                        span { class: "blueprint-dimension", "{ratio}" }
                                    }
                                }
                                label { class: "field wizard-image-alt",
                                    span { class: "field-label", "Alt text" }
                                    input {
                                        class: "field-input",
                                        r#type: "text",
                                        value: "{alt}",
                                        placeholder: "Describe the image",
                                        oninput: move |evt| {
                                            let mut c = alt_w.composition;
                                            let mut guard = c.write();
                                            let list = if is_cover { guard.cover.as_mut() } else { guard.previews.as_mut() };
                                            if let Some(list) = list
                                                && let Some(img) = list.get_mut(i)
                                            {
                                                img.alt = evt.value().into();
                                            }
                                            drop(guard);
                                            alt_w.mark_dirty();
                                        },
                                    }
                                }
                                button {
                                    class: "button button-ghost wizard-remove",
                                    onclick: move |_| {
                                        let mut c = remove_w.composition;
                                        let mut guard = c.write();
                                        let list = if is_cover { guard.cover.as_mut() } else { guard.previews.as_mut() };
                                        if let Some(list) = list
                                            && i < list.len()
                                        {
                                            list.remove(i);
                                        }
                                        drop(guard);
                                        remove_w.mark_dirty();
                                    },
                                    "Remove"
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    async fn handle_image_upload(self, is_cover: bool, file: FileData) {
        let filename = file.name();
        let mut status = self.image_upload;
        status.set(UploadStatus::Uploading(filename.clone()));
        let bytes = match file.read_bytes().await {
            Ok(bytes) => bytes.to_vec(),
            Err(_) => {
                status.set(UploadStatus::Error(
                    "Could not read the selected image.".into(),
                ));
                return;
            }
        };
        let mime = file
            .content_type()
            .filter(|s| !s.is_empty())
            .unwrap_or_else(|| mime_from_filename(&filename).to_string());
        match draft_client::upload_image(&self.client, &filename, &mime, bytes.clone()).await {
            Ok(image) => {
                {
                    let cid = image.image.blob().r#ref.as_str().to_string();
                    let data_uri = data_uri_from_bytes(&bytes, &mime);
                    let mut data_uris = self.image_data_uris;
                    data_uris.write().insert(cid.into(), data_uri);
                    let mut composition = self.composition;
                    let mut guard = composition.write();
                    let list = if is_cover {
                        guard.cover.get_or_insert_with(Vec::new)
                    } else {
                        guard.previews.get_or_insert_with(Vec::new)
                    };
                    list.push(image);
                }
                status.set(UploadStatus::Idle);
                let wizard = self.clone();
                wizard.save_draft().await;
            }
            Err(error) => {
                status.set(UploadStatus::Error(format!("Image upload failed: {error}")));
            }
        }
    }

    fn render_details(&self) -> Element {
        let composition = self.composition.read();
        let instructions = composition
            .instructions
            .clone()
            .unwrap_or_default()
            .join("\n");
        let license = composition
            .license
            .clone()
            .unwrap_or_else(|| DEFAULT_LICENSE.to_string());
        let tags = composition.tags.clone().unwrap_or_default();
        drop(composition);

        let tags = tags.join(", ");
        let license_w = self.clone();
        let tags_w = self.clone();
        let instructions_w = self.clone();
        rsx! {
            h2 { "Details" }
            label { class: "field",
                span { class: "field-label", "License" }
                input {
                    class: "field-input",
                    r#type: "text",
                    list: "license-options",
                    value: "{license}",
                    placeholder: "{DEFAULT_LICENSE}",
                    oninput: move |evt| {
                        let mut c = license_w.composition;
                        let v = evt.value();
                        c.write().license = if v.trim().is_empty() { None } else { Some(v) };
                        license_w.mark_dirty();
                    },
                }
                datalist { id: "license-options",
                    for option in LICENSE_OPTIONS {
                        option { key: "{option}", value: "{option}" }
                    }
                }
            }
            label { class: "field",
                span { class: "field-label", "Tags" }
                input {
                    class: "field-input",
                    r#type: "text",
                    value: "{tags}",
                    placeholder: "comma, separated",
                    oninput: move |evt| {
                        let mut c = tags_w.composition;
                        let parsed: Vec<String> = evt
                            .value()
                            .split(',')
                            .map(|t| t.trim().to_string())
                            .filter(|t| !t.is_empty())
                            .collect();
                        c.write().tags = if parsed.is_empty() { None } else { Some(parsed) };
                        tags_w.mark_dirty();
                    },
                }
            }
            label { class: "field",
                span { class: "field-label", "Instructions" }
                textarea {
                    class: "field-input field-textarea field-textarea-tall",
                    value: "{instructions}",
                    placeholder: "One step per line",
                    oninput: move |evt| {
                        let mut c = instructions_w.composition;
                        let value = evt.value();
                        // Store the text exactly as typed (split only on newlines) so
                        // spaces and blank lines survive editing; empty lines are
                        // dropped at assembly.
                        let lines: Vec<String> = value.split('\n').map(str::to_string).collect();
                        c.write().instructions = if value.trim().is_empty() { None } else { Some(lines) };
                        instructions_w.mark_dirty();
                    },
                }
            }
        }
    }

    fn render_review(&self) -> Element {
        let composition = self.composition.read();
        let assembled = composition.assemble();
        let name = composition.display_name().unwrap_or("Untitled").to_string();
        let license = composition
            .license
            .clone()
            .unwrap_or_else(|| DEFAULT_LICENSE.to_string());
        let summary = composition.summary.clone().unwrap_or_default();
        let tags = composition.tags.clone().unwrap_or_default();
        let models = composition.models.clone();
        let model_count = composition.model_count();
        let cover_count = composition.cover.as_ref().map(|c| c.len()).unwrap_or(0);
        let preview_count = composition.previews.as_ref().map(|p| p.len()).unwrap_or(0);
        let instructions = composition.instructions.clone().unwrap_or_default();
        drop(composition);

        let publishing = matches!(*self.phase.read(), PublishPhase::Publishing);
        let issues = assembled.as_ref().err().cloned();
        let name_error = issues
            .as_ref()
            .map(|e| e.has_field("name"))
            .unwrap_or(false);
        let license_error = issues
            .as_ref()
            .map(|e| e.has_field("license"))
            .unwrap_or(false);
        let name_row_class = if name_error {
            "wizard-review-row is-error"
        } else {
            "wizard-review-row"
        };
        let license_row_class = if license_error {
            "wizard-review-row is-error"
        } else {
            "wizard-review-row"
        };
        let can_publish = assembled.is_ok() && !publishing;
        let publish_w = self.clone();

        rsx! {
            h2 { "Review & publish" }
            p { class: "wizard-step-hint", "This is what becomes public when you publish." }

            div { class: "wizard-review",
                div { class: "{name_row_class}",
                    span { class: "field-label", "Name" }
                    span { "{name}" }
                }
                div { class: "{license_row_class}",
                    span { class: "field-label", "License" }
                    span { class: "status-pill", "{license}" }
                }
                if !summary.is_empty() {
                    div { class: "wizard-review-row",
                        span { class: "field-label", "Summary" }
                        span { "{summary}" }
                    }
                }
                if !tags.is_empty() {
                    div { class: "wizard-review-row",
                        span { class: "field-label", "Tags" }
                        div { class: "thing-card-chips",
                            for tag in tags { span { key: "{tag}", class: "status-pill", "{tag}" } }
                        }
                    }
                }
                div { class: "wizard-review-row",
                    span { class: "field-label", "Media" }
                    span { class: "card-meta", "{cover_count} cover · {preview_count} preview" }
                }
                div { class: "wizard-review-row",
                    span { class: "field-label", "Models ({model_count})" }
                    div { class: "wizard-review-models",
                        for (mi, model) in models.iter().enumerate() {
                            {
                                let mname = model.name.clone().unwrap_or_else(|| format!("Model {}", mi + 1));
                                let part_count = model.parts.len();
                                rsx! {
                                    div { key: "review-model-{mi}", class: "wizard-review-model",
                                        strong { "{mname}" }
                                        span { class: "card-meta", "{part_count} part(s)" }
                                    }
                                }
                            }
                        }
                    }
                }
                if !instructions.is_empty() {
                    div { class: "wizard-review-row",
                        span { class: "field-label", "Instructions" }
                        span { class: "card-meta", "{instructions.len()} step(s)" }
                    }
                }
            }

            if let Some(issues) = issues {
                div { class: "wizard-issues error-state state-card", role: "alert",
                    h3 { class: "wizard-subhead", "Fix these before publishing" }
                    ul { class: "instruction-list",
                        for issue in issues.issues {
                            li { key: "{issue.field}", "{issue.message}" }
                        }
                    }
                }
            }

            div { class: "wizard-publish-row",
                button {
                    class: "button button-primary wizard-publish",
                    disabled: !can_publish,
                    onclick: move |_| {
                        let wizard = publish_w.clone();
                        spawn(async move { wizard.publish().await });
                    },
                    if publishing { "Publishing…" } else { "Publish" }
                }
            }
        }
    }

    async fn publish(self) {
        // Persist the latest edits first. If that save fails, do NOT publish —
        // otherwise we would publish stale server-side draft state.
        self.clone().save_draft().await;
        if matches!(*self.save.read(), SaveState::Error) {
            let mut phase = self.phase;
            phase.set(PublishPhase::Failed(
                "Could not save your latest changes, so nothing was published. Try again.".into(),
            ));
            return;
        }
        let Some(id) = self.draft_id.read().clone() else {
            let mut phase = self.phase;
            phase.set(PublishPhase::Failed(
                "Draft was not saved; try again.".into(),
            ));
            return;
        };
        let mut phase = self.phase;
        phase.set(PublishPhase::Publishing);
        match draft_client::publish_draft(&self.client, &id).await {
            Ok(output) => {
                let uri = &output.thing.uri;
                let repo = uri.authority().to_string();
                let rkey = uri.rkey().map(|r| r.to_string()).unwrap_or_default();
                phase.set(PublishPhase::Published { repo, rkey });
            }
            Err(error) => {
                phase.set(PublishPhase::Failed(format!("Publish failed: {error}")));
            }
        }
    }

    fn render_published(&self, repo: String, rkey: String) -> Element {
        rsx! {
            section { class: "wizard-published state-card", aria_label: "Published",
                span { class: "status-pill", "Published" }
                h2 { "Your thing is live" }
                Link {
                    class: "button button-primary",
                    to: Route::ThingDetail { repo, rkey },
                    "View it"
                }
            }
        }
    }
}

/// Best-effort MIME type from a filename extension (the server re-sniffs image
/// dimensions independently).
fn mime_from_filename(name: &str) -> &'static str {
    match extension(name).as_deref() {
        Some("stl") => "model/stl",
        Some("obj") => "model/obj",
        Some("gltf") => "model/gltf+json",
        Some("glb") => "model/gltf-binary",
        Some("3mf") => "model/3mf",
        Some("step") | Some("stp") => "model/step",
        Some("png") => "image/png",
        Some("jpg") | Some("jpeg") => "image/jpeg",
        Some("webp") => "image/webp",
        Some("gif") => "image/gif",
        _ => "application/octet-stream",
    }
}

/// Short geometry format label from a filename, if recognized.
fn format_from_filename(name: &str) -> Option<String> {
    match extension(name).as_deref() {
        Some("stl") => Some("stl".into()),
        Some("obj") => Some("obj".into()),
        Some("gltf") | Some("glb") => Some("gltf".into()),
        Some("3mf") => Some("3mf".into()),
        Some("step") | Some("stp") => Some("step".into()),
        _ => None,
    }
}

fn extension(name: &str) -> Option<String> {
    name.rsplit_once('.')
        .map(|(_, ext)| ext.to_ascii_lowercase())
}

/// Human-readable byte size.
fn human_size(bytes: i64) -> String {
    let bytes = bytes.max(0) as f64;
    const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
    let mut value = bytes;
    let mut unit = 0;
    while value >= 1024.0 && unit < UNITS.len() - 1 {
        value /= 1024.0;
        unit += 1;
    }
    if unit == 0 {
        format!("{} {}", value as i64, UNITS[unit])
    } else {
        format!("{value:.1} {}", UNITS[unit])
    }
}

/// Attach a preview image to a draft part. v1 sets (overwrites) rather than
/// appends: each file stage creates a fresh `DraftPartInput`, so this is the
/// single auto-preview for that part.
fn attach_preview(part: &mut DraftPartInput, image: Image) {
    part.previews = Some(vec![image]);
}

/// Seed the thing-level cover from the first file of the first model when no
/// cover exists. Only fires for `model_index == 0` when the model has zero
/// parts *before* pushing the new one (the first file overall) and cover is
/// empty. Non-empty cover is never overwritten.
fn maybe_seed_cover(
    composition: &mut DraftThingInput,
    model_index: usize,
    parts_before_push: usize,
    image: &Image,
) {
    if model_index == 0
        && parts_before_push == 0
        && composition.cover.as_ref().is_none_or(Vec::is_empty)
    {
        composition.cover = Some(vec![image.clone()]);
    }
}

/// Render a PNG preview from staged file bytes and upload it. Wasm-only: on
/// non-wasm targets this is a no-op stub returning `None`.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
async fn generate_preview(
    client: PolymodelClient,
    filename: String,
    bytes: Vec<u8>,
    mut upload: Signal<UploadStatus>,
    mut image_data_uris: Signal<HashMap<SmolStr, SmolStr>>,
) -> Option<Image> {
    let png_bytes = match renderer_preview::render_preview_png(&filename, bytes, 1024, 1024).await {
        Ok(bytes) => bytes,
        Err(err) => {
            tracing::warn!(%err, "preview render failed for {filename}");
            return None;
        }
    };

    let alt = format!("Rendered preview of {filename}");
    upload.set(UploadStatus::Uploading(format!("preview of {filename}")));
    match draft_client::upload_image(&client, &alt, "image/png", png_bytes.clone()).await {
        Ok(image) => {
            let cid = image.image.blob().r#ref.as_str().to_string();
            let data_uri = data_uri_from_bytes(&png_bytes, "image/png");
            image_data_uris.write().insert(cid.into(), data_uri);
            Some(image)
        }
        Err(err) => {
            tracing::warn!(%err, "preview upload failed for {filename}");
            upload.set(UploadStatus::Idle);
            None
        }
    }
}

#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
async fn generate_preview(
    _client: PolymodelClient,
    _filename: String,
    _bytes: Vec<u8>,
    _upload: Signal<UploadStatus>,
    _image_data_uris: Signal<HashMap<SmolStr, SmolStr>>,
) -> Option<Image> {
    None
}

#[cfg(all(target_family = "wasm", target_os = "unknown"))]
mod renderer_preview {
    use polymodel_renderer_protocol::{
        MeshFormat, RendererCommand, RendererEvent, deserialize_event, serialize_command,
    };
    use wasm_bindgen::JsCast;
    use wasm_bindgen::closure::Closure;
    use wasm_bindgen::prelude::*;

    pub(super) async fn render_preview_png(
        filename: &str,
        bytes: Vec<u8>,
        width: u32,
        height: u32,
    ) -> Result<Vec<u8>, String> {
        let format = MeshFormat::from_extension(filename)
            .ok_or_else(|| "unsupported preview format".to_string())?;
        let blob_url = blob_url_for_bytes(bytes, mime_from_format(format))?;
        let result = request_preview_png(format, blob_url.clone(), width, height).await;
        let _ = web_sys::Url::revoke_object_url(&blob_url);
        result
    }

    fn mime_from_format(format: MeshFormat) -> &'static str {
        match format {
            MeshFormat::Stl => "model/stl",
            MeshFormat::Obj => "model/obj",
            MeshFormat::Gltf => "model/gltf+json",
            MeshFormat::Threemf => "model/3mf",
        }
    }

    fn blob_url_for_bytes(bytes: Vec<u8>, mime: &str) -> Result<String, String> {
        let array = js_sys::Uint8Array::from(bytes.as_slice());
        let parts = js_sys::Array::new();
        parts.push(&array);
        let options = web_sys::BlobPropertyBag::new();
        options.set_type(mime);
        let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options)
            .map_err(|e| format!("preview blob: {e:?}"))?;
        web_sys::Url::create_object_url_with_blob(&blob)
            .map_err(|e| format!("preview blob URL: {e:?}"))
    }

    async fn request_preview_png(
        format: MeshFormat,
        primary_url: String,
        width: u32,
        height: u32,
    ) -> Result<Vec<u8>, String> {
        let opts = web_sys::WorkerOptions::new();
        opts.set_type(web_sys::WorkerType::Module);
        let worker = web_sys::Worker::new_with_options("/renderer_worker_loader.js", &opts)
            .map_err(|e| format!("preview worker: {e:?}"))?;
        let request_id = js_sys::Math::random().to_bits() as u32;
        let command = RendererCommand::RenderPreviewImage {
            request_id,
            format,
            primary_url,
            width,
            height,
        };

        let command_bytes =
            serialize_command(&command).map_err(|e| format!("preview command serialize: {e}"))?;
        let worker_for_message = worker.clone();
        let worker_for_error = worker.clone();
        let promise = js_sys::Promise::new(&mut move |resolve, reject| {
            let command_bytes = command_bytes.clone();
            let worker_for_message = worker_for_message.clone();
            let resolve_message = resolve.clone();
            let reject_message = reject.clone();
            let onmessage = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| {
                let data = event.data();
                if data.as_string().as_deref() == Some("__worker_ready") {
                    let array = js_sys::Uint8Array::from(command_bytes.as_slice());
                    if let Err(e) = worker_for_message.post_message(&array) {
                        let _ = reject_message.call1(
                            &JsValue::NULL,
                            &JsValue::from_str(&format!("preview command post: {e:?}")),
                        );
                    }
                    return;
                }

                let Ok(array) = data.dyn_into::<js_sys::Uint8Array>() else {
                    return;
                };
                let bytes = array.to_vec();
                match deserialize_event(&bytes) {
                    Ok(RendererEvent::PreviewImageRendered {
                        request_id: event_request_id,
                        png,
                    }) if event_request_id == request_id => {
                        let array = js_sys::Uint8Array::from(png.as_slice());
                        let _ = resolve_message.call1(&JsValue::NULL, &array);
                    }
                    Ok(RendererEvent::PreviewImageError {
                        request_id: event_request_id,
                        message,
                    }) if event_request_id == request_id => {
                        let _ = reject_message.call1(&JsValue::NULL, &JsValue::from_str(&message));
                    }
                    Ok(_) => {}
                    Err(e) => {
                        let _ = reject_message.call1(
                            &JsValue::NULL,
                            &JsValue::from_str(&format!("preview event deserialize: {e}")),
                        );
                    }
                }
            }) as Box<dyn FnMut(web_sys::MessageEvent)>);

            let reject_error = reject.clone();
            let onerror = Closure::wrap(Box::new(move |event: web_sys::ErrorEvent| {
                let _ = reject_error.call1(&JsValue::NULL, &JsValue::from_str(&event.message()));
            }) as Box<dyn FnMut(web_sys::ErrorEvent)>);

            worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
            worker.set_onerror(Some(onerror.as_ref().unchecked_ref()));
            onmessage.forget();
            onerror.forget();
        });

        let output = wasm_bindgen_futures::JsFuture::from(promise)
            .await
            .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?;
        worker_for_error.terminate();
        Ok(js_sys::Uint8Array::new(&output).to_vec())
    }
}

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

    fn alt_str(image: &Image) -> &str {
        <jacquard_common::SmolStr as AsRef<str>>::as_ref(&image.alt)
    }

    #[test]
    fn mime_and_format_from_extension() {
        assert_eq!(mime_from_filename("Tray.STL"), "model/stl");
        assert_eq!(mime_from_filename("photo.png"), "image/png");
        assert_eq!(mime_from_filename("weird"), "application/octet-stream");
        assert_eq!(format_from_filename("a.glb").as_deref(), Some("gltf"));
        assert_eq!(format_from_filename("a.txt"), None);
    }

    #[test]
    fn human_size_scales_units() {
        assert_eq!(human_size(512), "512 B");
        assert_eq!(human_size(2048), "2.0 KB");
        assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB");
    }

    fn test_image(alt: &str) -> Image {
        use jacquard_common::types::blob::{Blob, BlobRef, MimeType};
        use jacquard_common::types::cid::CidLink;
        use polymodel_api::space_polymodel::library::AspectRatio;
        Image {
            alt: alt.into(),
            aspect_ratio: AspectRatio {
                height: 1024,
                width: 1024,
                extra_data: None,
            },
            image: BlobRef::Blob(Blob {
                r#ref: CidLink::from_str(
                    "bafyreigqdh7sjw7w6d7bk6f4x4n4n4n4n4n4n4n4n4n4n4n4n4n4n4n4n4n4",
                )
                .unwrap(),
                mime_type: MimeType::new("image/png".into()),
                size: 42,
            }),
            extra_data: None,
        }
    }

    #[test]
    fn attach_preview_sets_previews_when_empty() {
        let mut part = DraftPartInput {
            name: Some("Bracket".into()),
            upload_id: Some("upload-1".into()),
            ..Default::default()
        };
        assert!(part.previews.is_none());
        attach_preview(&mut part, test_image("Rendered preview of Bracket.stl"));
        assert_eq!(part.previews.as_ref().unwrap().len(), 1);
        assert_eq!(
            alt_str(&part.previews.as_ref().unwrap()[0]),
            "Rendered preview of Bracket.stl"
        );
    }

    #[test]
    fn attach_preview_overwrites_existing_previews() {
        let mut part = DraftPartInput {
            name: Some("Bracket".into()),
            previews: Some(vec![test_image("old user-uploaded preview")]),
            ..Default::default()
        };
        assert_eq!(part.previews.as_ref().unwrap().len(), 1);
        attach_preview(&mut part, test_image("Rendered preview of Bracket.stl"));
        assert_eq!(part.previews.as_ref().unwrap().len(), 1);
        assert_eq!(
            alt_str(&part.previews.as_ref().unwrap()[0]),
            "Rendered preview of Bracket.stl"
        );
    }

    #[test]
    fn maybe_seed_cover_seeds_first_file_first_model() {
        let mut composition = DraftThingInput::default();
        let image = test_image("Rendered preview of Tray.stl");
        maybe_seed_cover(&mut composition, 0, 0, &image);
        assert_eq!(composition.cover.as_ref().unwrap().len(), 1);
        assert_eq!(
            alt_str(&composition.cover.as_ref().unwrap()[0]),
            "Rendered preview of Tray.stl"
        );
    }

    #[test]
    fn maybe_seed_cover_does_not_seed_for_nonzero_model() {
        let mut composition = DraftThingInput::default();
        let image = test_image("Rendered preview of Lid.stl");
        maybe_seed_cover(&mut composition, 1, 0, &image);
        assert!(composition.cover.is_none());
    }

    #[test]
    fn maybe_seed_cover_does_not_seed_when_parts_already_exist() {
        let mut composition = DraftThingInput::default();
        let image = test_image("Rendered preview of second.stl");
        maybe_seed_cover(&mut composition, 0, 1, &image);
        assert!(composition.cover.is_none());
    }

    #[test]
    fn maybe_seed_cover_does_not_overwrite_nonempty_cover() {
        let mut composition = DraftThingInput {
            cover: Some(vec![test_image("user cover")]),
            ..Default::default()
        };
        let image = test_image("Rendered preview of Tray.stl");
        maybe_seed_cover(&mut composition, 0, 0, &image);
        assert_eq!(composition.cover.as_ref().unwrap().len(), 1);
        assert_eq!(
            alt_str(&composition.cover.as_ref().unwrap()[0]),
            "user cover"
        );
    }

    #[test]
    fn maybe_seed_cover_re_stage_after_delete_does_not_seed_over_user_cover() {
        // Edge: model-0 parts deleted back to zero, but a user manually set a
        // cover. The cover must NOT be overwritten by a new auto-preview.
        let mut composition = DraftThingInput {
            cover: Some(vec![test_image("user cover")]),
            ..Default::default()
        };
        let image = test_image("Rendered preview of re-staged.stl");
        maybe_seed_cover(&mut composition, 0, 0, &image);
        assert_eq!(
            alt_str(&composition.cover.as_ref().unwrap()[0]),
            "user cover"
        );
    }

    #[test]
    fn maybe_seed_cover_re_stage_after_delete_seeds_when_both_empty() {
        // Edge: model-0 parts deleted back to zero AND cover is empty. A new
        // first file should re-seed the cover.
        let mut composition = DraftThingInput::default();
        let image = test_image("Rendered preview of fresh.stl");
        maybe_seed_cover(&mut composition, 0, 0, &image);
        assert_eq!(composition.cover.as_ref().unwrap().len(), 1);
        assert_eq!(
            alt_str(&composition.cover.as_ref().unwrap()[0]),
            "Rendered preview of fresh.stl"
        );
    }
}
