//! `scenario` — batch-generate images from an HJSON file that mixes scenes,
//! weather, and per-task prompts. See README for the schema and an example.

use anyhow::{Context, Result, anyhow, bail};
use candle_core::Device;
use clap::Args as ClapArgs;
use console::style;
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;

use crate::imaging::sizes::Size;
use crate::imaging::upscale::{EsrganPipeline, Method as UpscaleMethod};
use crate::pipelines::flux;
use crate::pipelines::ip_adapter::IdentityKind;
use crate::pipelines::lora::LoraSpec;
use crate::pipelines::portrait;
use crate::pipelines::scheduler::SchedulerKind;
use crate::pipelines::stylize;
use crate::pipelines::t2i::{GenRequest, LoadRequest, Pipeline, Variant};
use crate::style::{
    combine_negative, log_style_prep, parse_resolved_loras, prepend_trigger, StylePrepRequest,
    StyleSession,
};

#[derive(ClapArgs, Debug)]
pub struct ScenarioArgs {
    /// Path to the HJSON scenario file.
    pub file: PathBuf,

    /// Validate, print every task's planned prompts, but skip generation.
    /// Does NOT call the enhancer (no API cost).
    #[arg(long)]
    pub dry_run: bool,

    /// v0.17 phase 5: skip tasks whose **every** expected output
    /// PNG already exists. Lets a crashed / Ctrl-C'd scenario
    /// pick up where it left off without restarting from task 0.
    /// Task name + seed-based filenames are checked under
    /// `<out>/<task-name>/`. Mutually exclusive with `--force`.
    #[arg(long, default_value_t = false, conflicts_with = "force")]
    pub resume: bool,

    /// v0.17 phase 5: regenerate every task even when outputs
    /// already exist on disk. Default behaviour overwrites
    /// existing files silently — `--force` makes the intent
    /// explicit (and pairs with future safety checks). Mutually
    /// exclusive with `--resume`.
    #[arg(long, default_value_t = false, conflicts_with = "resume")]
    pub force: bool,

    /// v0.19: run only the named tasks. Comma-separated list of task
    /// `name:` values from the scenario file. Useful for iterating
    /// on a single task without re-running the whole batch. Tasks
    /// not in the list are silently skipped (no output written).
    /// Composes with `--resume` (a named task already on disk still
    /// gets skipped under resume semantics).
    #[arg(long, value_delimiter = ',', value_name = "NAME[,NAME,…]")]
    pub only: Vec<String>,

    /// v0.19: run only the first N tasks (in scenario file order).
    /// Handy for sanity-checking a long batch before launching the
    /// full run. `0` means "no limit" (same as omitting the flag).
    /// Composes with `--only` (the limit applies after the
    /// name-filter; e.g. `--only a,b,c --limit 2` runs `a` and `b`).
    #[arg(long, default_value_t = 0, value_name = "N")]
    pub limit: u32,
}

#[derive(Debug, Deserialize)]
struct ScenarioFile {
    // ---------- global generation parameters ----------
    model: Option<String>,
    device: Option<String>,
    size: Option<String>,
    aspect: Option<String>,
    base: Option<u32>,
    count: Option<u32>,
    steps: Option<usize>,
    guidance: Option<f64>,
    seed: Option<u64>,
    out: Option<PathBuf>,

    #[serde(default)]
    loras: Vec<String>,
    #[serde(rename = "lora-scale")]
    lora_scale: Option<f32>,

    scheduler: Option<String>,
    refine: Option<usize>,
    #[serde(rename = "refine-strength")]
    refine_strength: Option<f32>,

    /// If true (and model is SDXL/SDXL-Turbo) use the real SDXL refiner
    /// UNet for the last fraction of every task's schedule.
    #[serde(default)]
    refiner: bool,
    #[serde(rename = "refiner-frac")]
    refiner_frac: Option<f32>,

    // ---------- v0.13 phase 10: GGUF + quant + tiled ----------
    /// v0.13 phase 1b: load T5-XXL as a quantized GGUF when running
    /// Flux GGUF. Requires `model: flux-*-gguf`. Bails loud otherwise.
    #[serde(rename = "quantize-t5", default)]
    quantize_t5: bool,
    /// v0.13 phase 5: Flux GGUF quant level (e.g. `Q5_K_M`, `Q8_0`,
    /// `F16`). `None` falls back to `Q4_K_S`. Only meaningful with a
    /// GGUF Flux model.
    #[serde(rename = "quant-level", default)]
    quant_level: Option<String>,
    /// v0.13 phase 5: T5-XXL GGUF quant level (e.g. `Q5_K_M`, `Q8_0`).
    /// `None` falls back to `Q4_K_S`. Only meaningful with
    /// `quantize-t5: true`.
    #[serde(rename = "t5-quant-level", default)]
    t5_quant_level: Option<String>,
    /// v0.13 phase 4/9: MultiDiffusion-style tiled denoise. When set,
    /// every task in the scenario runs through the tiled path (Flux,
    /// SDXL, SD 1.5/2.1, or SD3). Per-task `tiled:` overrides this.
    #[serde(default)]
    tiled: Option<TiledCfg>,

    /// v0.15 phase 7a: scenario-wide Flux distillation preset (Hyper-FLUX
    /// or FLUX-Turbo) — applied to every Flux task by default. Per-task
    /// `fast:` overrides. Accepts the same preset names as the CLI
    /// `--fast` flag: `hyper-8`, `hyper-16`, `turbo-alpha`. Non-Flux
    /// tasks ignore with a warning.
    #[serde(default)]
    fast: Option<String>,

    /// v0.25: scenario-wide art-medium preset. Bundled choices: `ink-wash`,
    /// `watercolor`, `oil-painting`, `charcoal`, `pencil`, `chalk-pastel`,
    /// `linocut`, `gouache`. Composes the prompt + suggests sampler /
    /// steps / guidance. Per-task `look:` overrides. Override-only:
    /// scenario-level steps / guidance / scheduler / loras take
    /// precedence.
    ///
    /// **Auto-LoRA discovery is NOT active in scenarios** (v0.25
    /// scope). The prompt prefix / suffix / sampler hints still apply;
    /// users who want a specific LoRA should supply `loras: [...]`
    /// at the scenario or task level. Discovery integration is
    /// deferred to v0.26.
    #[serde(default)]
    look: Option<String>,

    /// v0.25: scenario-wide subject-domain preset (`anime`). Independent
    /// axis from `look:`; composes additively. Per-task `genre:`
    /// overrides.
    #[serde(default)]
    genre: Option<String>,

    /// v0.25: skip remote LoRA discovery for `look:` / `genre:` —
    /// effectively a no-op in v0.25 scenarios (discovery isn't wired
    /// here yet) but accepted for forward-compatibility. Per-task
    /// `offline:` overrides.
    #[serde(default)]
    offline: Option<bool>,

    /// v0.15 phase 7a / v0.18: scenario-wide conditioning image. Three
    /// roles depending on `model:`:
    ///   * `flux-canny-dev` — canny edge map (channel-concat 128ch img_in)
    ///   * `flux-depth-dev` — depth map (channel-concat 128ch img_in)
    ///   * `flux-kontext-dev` — reference image to edit (seq-concat,
    ///                          img_ids[..., 0] = 1)
    /// Per-task `concept-image:` overrides. Ignored on non-conditioning
    /// models.
    #[serde(rename = "concept-image", default)]
    concept_image: Option<PathBuf>,

    /// v0.18 Kontext phase 4: opt-in aspect-bucket snap. When `true`
    /// AND `model:` is `flux-kontext-dev` / `flux-kontext-dev-gguf`,
    /// `size:` snaps to the closest of 17 BFL-recommended Kontext
    /// resolutions before VAE encoding. Per-task `kontext-bucket:`
    /// overrides.
    #[serde(rename = "kontext-bucket", default)]
    kontext_bucket: Option<bool>,

    // ---------- prompt-assembly fragments ----------
    #[serde(rename = "lora-header", default)]
    lora_header: String,
    #[serde(rename = "lora-footer", default)]
    lora_footer: String,
    #[serde(rename = "prompt-header", default)]
    prompt_header: String,
    #[serde(rename = "prompt-footer", default)]
    prompt_footer: String,

    // Accept both correct + the typo-spelling commonly seen in the wild.
    #[serde(alias = "enchancer")]
    enhancer: Option<String>,

    #[serde(default)]
    negative: String,

    // ---------- style detection / transfer ----------
    /// Detect art style from this photo and load the matching LoRAs
    /// from the catalog. Applied globally to every task. Conflicts
    /// with `loras: [...]` — catalog LoRAs win with a warning.
    #[serde(rename = "style-ref", default)]
    style_ref: Option<PathBuf>,
    /// Pick a style by id from the catalog. Bypasses detection when
    /// used alone; overrides the detection result when combined with
    /// `style-ref`.
    #[serde(default)]
    style: Option<String>,
    /// Multiplier applied to every catalog LoRA's :scale. Defaults to 1.0.
    #[serde(rename = "style-strength", default)]
    style_strength: Option<f32>,
    /// Override the bundled style catalog directory.
    #[serde(rename = "style-catalog", default)]
    style_catalog: Option<PathBuf>,

    // ---------- artefact compositing ----------
    /// Override the bundled artefact library directory. Per-task
    /// `artefacts: [...]` references resolve against this library.
    #[serde(rename = "artefact-library", default)]
    artefact_library: Option<PathBuf>,
    /// Override the default zone grid (normalized `[0, 1]` band
    /// extents). Missing bands fall back to the default 4×3 grid.
    #[serde(default)]
    zones: crate::artefacts::ZoneOverrides,
    /// v2: enable the masked img2img blending pass after alpha
    /// compositing. Set per-task via `artefact-blend: true`, or
    /// scenario-wide here. Default off (v1 alpha-only).
    #[serde(rename = "artefact-blend", default)]
    artefact_blend: bool,
    /// img2img strength for the v2 blend pass. Sweet spot: 0.25–0.4.
    #[serde(rename = "artefact-blend-strength", default)]
    artefact_blend_strength: Option<f32>,
    /// v3: derive zone extents from the generated image's own depth +
    /// luminance instead of the rigid 4×3 grid (with per-band
    /// `zones:` overrides applied where smart resolution comes up
    /// empty). Per-task `smart-zones: true/false` overrides. Default
    /// off.
    #[serde(rename = "smart-zones", default)]
    smart_zones: bool,

    // ---------- post-generate options ----------
    #[serde(default)]
    upscale: UpscaleConfig,

    // ---------- catalogs ----------
    #[serde(default)]
    scene: Vec<NamedPrompt>,
    #[serde(default)]
    weather: Vec<NamedPrompt>,
    /// Named identities tasks can pull in via their own `personas: [name]`
    /// list. Each persona is a reference photo + per-persona portrait
    /// parameters; the task supplies scene/weather/prompt/size/sampler.
    #[serde(default)]
    personas: Vec<PersonaDef>,
    #[serde(default)]
    tasks: Vec<TaskDef>,
}

/// Top-level `personas: [ {...}, ... ]` entry. Identity-defining settings only
/// — task-side concerns (scene, prompt, size) live on `TaskDef`.
/// HJSON shape for one entry in `PersonaDef.photos`. Untagged so HJSON
/// authors can use either `"path:weight"` shorthand strings (matching the
/// CLI `--photo` grammar) or `{ path: "...", weight: 0.7 }` objects.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum PersonaPhoto {
    Shorthand(String),
    Full {
        path: PathBuf,
        #[serde(default)]
        weight: Option<f32>,
    },
}

impl PersonaPhoto {
    fn to_weighted(&self) -> Result<crate::pipelines::ip_adapter::WeightedPhoto> {
        match self {
            Self::Shorthand(s) => s.parse::<crate::pipelines::ip_adapter::WeightedPhoto>(),
            Self::Full { path, weight } => Ok(crate::pipelines::ip_adapter::WeightedPhoto {
                path: path.clone(),
                weight: *weight,
            }),
        }
    }
}

#[derive(Debug, Deserialize)]
struct PersonaDef {
    /// Referenced by `task.personas: [<name>]`. Must be unique within the file.
    name: String,
    /// Single reference photo. Mutually exclusive with `photos`.
    #[serde(default)]
    photo: Option<PathBuf>,
    /// Multiple weighted reference photos for embedding-space merging
    /// (averaging facial features across photos). Mutually exclusive
    /// with `photo`. Each entry accepts either a shorthand
    /// `"path:weight"` string or a `{ path: "...", weight: 0.7 }`
    /// object; weights are normalized to sum to 1.0. Same grammar as
    /// the CLI's repeatable `--photo` flag.
    #[serde(default)]
    photos: Vec<PersonaPhoto>,
    /// Which identity strategy. Defaults to `plus-face`. Other options:
    /// `plus-face-sdxl`, `faceid`, `faceid-sdxl`.
    #[serde(default)]
    identity: Option<String>,
    /// IP-Adapter scale on the image tokens (0..). Defaults to 0.8.
    #[serde(rename = "face-strength", default)]
    face_strength: Option<f32>,
    /// Optional face bbox in the photo, `[x0, y0, x1, y1]` normalised to
    /// `[0, 1]` with origin top-left. Used by FaceID strategies to crop
    /// the photo to the face region before ArcFace embedding; CLIP-H
    /// strategies ignore it. Optional SCRFD auto-detection (PLAKAT_SCRFD_*)
    /// can fill this in from any photo.
    #[serde(rename = "face-bbox", default)]
    face_bbox: Option<[f32; 4]>,
    /// Optional 5-point landmarks in the photo. When set,
    /// FaceID strategies perform a similarity-transform alignment to
    /// ArcFace's canonical 112×112 template — the proper alignment.
    /// Takes precedence over `face-bbox` when both are set.
    ///
    /// Format: `[[x, y]; 5]` normalised, order:
    /// `left_eye, right_eye, nose, left_mouth_corner, right_mouth_corner`.
    /// CLIP-H strategies (`plus-face*`) ignore this field.
    #[serde(rename = "face-landmarks", default)]
    face_landmarks: Option<[[f32; 2]; 5]>,
    /// Optional persona-specific negative prompt (e.g. "no glasses, no beard").
    /// Prepended to the task's effective negative when this persona is
    /// imposed — kept with the persona because it describes the *who*, not
    /// the scene.
    #[serde(default)]
    negative: Option<String>,
}

impl PersonaDef {
    /// Resolve the persona's `photo` (legacy single-ref) and `photos`
    /// (multi-ref) fields into a normalized list of weighted photos
    /// suitable for the portrait pipeline. Validates mutual-exclusion
    /// and that at least one is set.
    fn resolve_photos(&self) -> Result<Vec<crate::pipelines::ip_adapter::WeightedPhoto>> {
        let mut out: Vec<crate::pipelines::ip_adapter::WeightedPhoto> =
            match (&self.photo, self.photos.is_empty()) {
                (Some(_), false) => bail!(
                    "persona {:?}: `photo` and `photos` are mutually exclusive — \
                     use one or the other",
                    self.name
                ),
                (None, true) => bail!(
                    "persona {:?}: missing reference photo (set `photo: <path>` or \
                     `photos: [...]`)",
                    self.name
                ),
                (Some(p), true) => vec![
                    crate::pipelines::ip_adapter::WeightedPhoto::single(p.clone()),
                ],
                (None, false) => self
                    .photos
                    .iter()
                    .map(PersonaPhoto::to_weighted)
                    .collect::<Result<_>>()
                    .with_context(|| format!("parsing persona {:?} photos", self.name))?,
            };
        crate::pipelines::ip_adapter::normalize_photo_weights(&mut out)
            .with_context(|| format!("normalizing persona {:?} photos", self.name))?;
        Ok(out)
    }

    /// Convenience: the first photo path, for log messages / existence
    /// checks. With multi-photo personas we still need to point users
    /// at a representative location; the first one is fine for that.
    fn primary_photo_path(&self) -> Option<PathBuf> {
        if let Some(p) = &self.photo {
            return Some(p.clone());
        }
        self.photos.first().and_then(|pp| match pp {
            PersonaPhoto::Shorthand(s) => s
                .parse::<crate::pipelines::ip_adapter::WeightedPhoto>()
                .ok()
                .map(|w| w.path),
            PersonaPhoto::Full { path, .. } => Some(path.clone()),
        })
    }
}

/// Top-level `upscale: { ... }` section.
#[derive(Debug, Deserialize)]
struct UpscaleConfig {
    /// Enable the post-generate upscale pass.
    #[serde(default, alias = "enabled")]
    upscale: bool,
    /// Scale factor (2× by default).
    #[serde(default = "default_upscale_scale")]
    scale: f32,
    /// Filter: nearest | bilinear | bicubic | lanczos.
    #[serde(default = "default_upscale_method")]
    method: String,
}

impl Default for UpscaleConfig {
    fn default() -> Self {
        Self {
            upscale: false,
            scale: default_upscale_scale(),
            method: default_upscale_method(),
        }
    }
}

fn default_upscale_scale() -> f32 {
    2.0
}
fn default_upscale_method() -> String {
    "lanczos".to_string()
}

#[derive(Debug, Deserialize)]
struct NamedPrompt {
    name: String,
    prompt: String,
}

#[derive(Debug, Deserialize)]
struct TaskDef {
    name: String,
    scene: String,
    weather: String,
    prompt: String,

    // ---------- per-task style pass ----------
    /// Optional path to a style reference image. If set, every generated
    /// image for this task is also run through `stylize` (IP-Adapter) using
    /// this image as REF. Original + styled both land in the task directory.
    #[serde(default)]
    style: Option<PathBuf>,
    /// IP-Adapter strength for the style pass (0..1). Higher = more REF.
    #[serde(rename = "style-strength", default)]
    style_strength: Option<f32>,

    // ---------- per-task overrides for global fields ----------
    // When set, override the scenario's global value for THIS task only.
    // Fields not listed here (model, device, loras, lora-scale, enhancer,
    // out, upscale.*) stay global because changing them would force the
    // shared pipeline to reload.
    #[serde(default)]
    size: Option<String>,
    #[serde(default)]
    aspect: Option<String>,
    #[serde(default)]
    count: Option<u32>,
    #[serde(default)]
    steps: Option<usize>,
    #[serde(default)]
    guidance: Option<f64>,
    /// Per-task seed override. When set, this task uses exactly this seed
    /// (the global seed_offset counter still advances so later tasks are
    /// unaffected).
    #[serde(default)]
    seed: Option<u64>,
    #[serde(default)]
    negative: Option<String>,
    #[serde(default)]
    scheduler: Option<String>,
    #[serde(default)]
    refine: Option<usize>,
    #[serde(rename = "refine-strength", default)]
    refine_strength: Option<f32>,
    #[serde(rename = "refiner-frac", default)]
    refiner_frac: Option<f32>,

    /// Personas (from the top-level `personas` list) to impose into this
    /// task's output. Two accepted forms:
    ///
    /// Single persona, whole image:
    ///     personas: [ alice ]
    ///
    /// Multi-persona, region-masked compositing:
    ///     personas: [
    ///         { name: alice, bbox: [0.0, 0.1, 0.45, 0.9] }
    ///         { name: bob,   bbox: [0.55, 0.1, 1.0, 0.9] }
    ///     ]
    ///
    /// `bbox` is `[x0, y0, x1, y1]` normalised to `[0, 1]`. Mixing forms
    /// within one task is rejected at load time.
    #[serde(default)]
    personas: Option<Vec<PersonaRef>>,

    // ---------- per-task catalog-style override ----------
    /// Detect art style from this photo. Fully overrides the scenario's
    /// global `style-ref` / `style` for this task only; the catalog
    /// trigger phrase and `negative_extras` are recomputed against the
    /// scenario's bare `lora-header` and `negative` (NOT against the
    /// global style's effective values).
    ///
    /// Per-task catalog style applies **trigger and negative only** —
    /// LoRAs cannot be swapped per-task because scenarios share a
    /// pre-loaded pipeline. If the resolved style would have required
    /// different LoRAs than the scenario's global set, plakat warns and
    /// proceeds (trigger + negative may still help, but the LoRA's
    /// visual contribution is missing). For per-task LoRA swaps,
    /// split into separate scenarios.
    ///
    /// **Note on naming:** distinct from per-task `style:` (IP-Adapter
    /// REF stylize pass) — different feature.
    #[serde(rename = "style-ref", default)]
    style_ref_catalog: Option<PathBuf>,

    /// Composite named artefacts (PNG cutouts from the library) onto
    /// this task's output. Accepts either CLI-grammar shorthand
    /// strings (`"oak@middle_plan/left"`) or full objects with
    /// per-artefact `offset`, `anchor`, `flip`, `alpha` overrides.
    /// Compositing happens BEFORE any per-task stylize / upscale pass,
    /// so the IP-Adapter stylize re-paints over the composited
    /// artefacts (often a feature: it unifies the palette).
    #[serde(default)]
    artefacts: Vec<crate::artefacts::ArtefactSpecEntry>,

    /// v2: per-task override for the masked img2img blend pass.
    /// `None` inherits the scenario-level `artefact-blend:` field.
    #[serde(rename = "artefact-blend", default)]
    artefact_blend: Option<bool>,

    /// v2: per-task override for blend strength. `None` inherits the
    /// scenario-level `artefact-blend-strength:` (default 0.3).
    #[serde(rename = "artefact-blend-strength", default)]
    artefact_blend_strength: Option<f32>,

    /// v3: per-task override for smart zones. `None` inherits the
    /// scenario-level `smart-zones:` flag.
    #[serde(rename = "smart-zones", default)]
    smart_zones: Option<bool>,

    /// v0.9: ControlNet block. When set, the named conditioner
    /// guides every denoise step for this task. `strength` defaults
    /// to 1.0 if omitted.
    ///
    /// ```hjson
    /// control: { kind: depth, image: ./hint.png, strength: 0.85 }
    /// ```
    /// Mutually exclusive with `controls:` (the Vec form below).
    #[serde(default)]
    control: Option<ControlSpec>,

    /// v0.13 phase 11: multi-ControlNet per task. Each entry is a
    /// `ControlSpec` (same grammar as the singular `control:` block).
    /// Residuals from all entries sum per denoise step.
    ///
    /// ```hjson
    /// controls: [
    ///   { kind: depth, image: ./d.png, strength: 0.8 },
    ///   { kind: canny, auto-from: ./ref.png, strength: 0.5, end: 0.5 },
    /// ]
    /// ```
    /// Mutually exclusive with the singular `control:` block. Both
    /// SD and Flux multi-CN flows compose with the scenario
    /// `tiled:` config (per phase 9).
    #[serde(default)]
    controls: Option<Vec<ControlSpec>>,

    // ---------- v0.13 phase 10/11: img2img / Fill / inpaint inputs ----
    /// v0.13 phase 3 / phase 11: init image. For Flux non-Fill →
    /// img2img (uses `strength`). For Flux Fill or SD inpaint models →
    /// inpaint with `mask`. For plain SD t2i models → RePaint-style
    /// masked img2img if `mask:` is also set, else plain img2img.
    /// Empty by default.
    #[serde(rename = "init-image", default)]
    init_image: Option<PathBuf>,
    /// v0.13 phase 2 / phase 11: inpaint mask (white = inpaint, black
    /// = preserve). Requires `init-image`. Pure t2i tasks ignore.
    #[serde(default)]
    mask: Option<PathBuf>,
    /// v0.13 phase 3: img2img strength in `[0, 1]`. `None` → pipeline
    /// default (Flux 0.85; SD 0.6 for img2img, 1.0 for inpaint).
    #[serde(default)]
    strength: Option<f32>,
    /// v0.13 phase 11: SD inpaint mask feathering (px). Softens the
    /// boundary between preserved and inpainted regions. Default 8.
    /// Ignored on Flux (mask is binarised before the patching pack).
    #[serde(rename = "mask-feather", default)]
    mask_feather: Option<u32>,
    /// v0.13 phase 11: invert mask polarity (treat black as inpaint).
    #[serde(rename = "mask-invert", default)]
    mask_invert: Option<bool>,

    /// v0.13 phase 11: outpaint task — extend the canvas of
    /// `init-image` past its borders, build a mask covering the new
    /// region, and run the inpaint pipeline. Mutually exclusive with
    /// `mask:` (the outpaint block synthesises the mask).
    ///
    /// ```hjson
    /// outpaint: { expand: 256 }                         # all sides
    /// outpaint: { left: 512, right: 512 }               # panoramic
    /// outpaint: { top: 128, bottom: 256 }               # vertical
    /// ```
    #[serde(default)]
    outpaint: Option<OutpaintSpec>,

    /// v0.14 phase 3c: zero or more Flux Redux reference images for
    /// this task. Each string parses as `path` (weight = 1.0) or
    /// `path:weight=0.7`. Only meaningful for Flux variants except
    /// Fill (which has an incompatible `img_in` shape).
    ///
    /// ```hjson
    /// redux-images: [ ./refs/cat1.jpg, ./refs/cat2.jpg:weight=0.6 ]
    /// ```
    ///
    /// Cap of 4 enforced inside `Pipeline::generate`.
    #[serde(rename = "redux-images", default)]
    redux_images: Vec<String>,

    /// v0.15 phase 7a: per-task Flux distillation preset (`hyper-8`,
    /// `hyper-16`, `turbo-alpha`). Overrides the scenario-level `fast:`.
    /// Applies the preset's LoRA + step/guidance defaults the same way
    /// `plakat generate --fast PRESET` does. Bails if combined with a
    /// non-Flux task model.
    #[serde(default)]
    fast: Option<String>,

    /// v0.25: per-task art-medium preset. Overrides scenario-level
    /// `look:`. Same accepted names + semantics as the CLI `--look`
    /// flag.
    #[serde(default)]
    look: Option<String>,

    /// v0.25: per-task subject-domain preset (`anime`). Overrides
    /// scenario-level `genre:`.
    #[serde(default)]
    genre: Option<String>,

    /// v0.25: per-task `--offline` override. `None` inherits the
    /// scenario-level setting.
    #[serde(default)]
    offline: Option<bool>,

    /// v0.15 phase 7a / v0.18: per-task conditioning image. Required
    /// on Flux concept variants (Canny-dev / Depth-dev) and Kontext
    /// (Kontext-dev). Falls back to scenario-level `concept-image:`.
    #[serde(rename = "concept-image", default)]
    concept_image: Option<PathBuf>,

    /// v0.18 Kontext phase 4: per-task Kontext bucket override. When
    /// `Some(true)`, snap `size:` to the closest of 17 BFL Kontext
    /// resolutions before VAE encoding (Kontext models only). Falls
    /// back to the scenario-level `kontext-bucket:` when `None`.
    #[serde(rename = "kontext-bucket", default)]
    kontext_bucket: Option<bool>,

    /// v0.15 phase 7a: per-task prompt enhancement override. Three
    /// forms:
    ///   * absent — inherit scenario-level `enhancer:`
    ///   * `enhance: "deepseek"` — use this provider for this task
    ///   * `enhance: false` — opt out of enhancement entirely for this
    ///     task even when the scenario has one configured
    #[serde(default)]
    enhance: Option<EnhanceCfg>,

    /// v0.15 phase 7a: per-task tiled-denoise override.
    ///   * absent — inherit scenario-level `tiled:`
    ///   * `tiled: false` — force off (e.g. a small portrait task in
    ///     a mostly-4K scenario)
    ///   * `tiled: { size: 1024, stride: 768 }` — override config
    #[serde(default)]
    tiled: Option<TaskTiledCfg>,

    /// v0.15 phase 7b-7: per-task LoRA stack (ADDITIVE on top of
    /// the scenario-level LoRAs). Each string parses via the same
    /// `LoraSpec` grammar the CLI `--lora` flag uses: local path,
    /// HF repo, or `:weight` suffix.
    ///
    /// Supported on Flux (BF16 / GGUF / NF4) and SD3 / SD3.5.
    /// SD-family tasks (`sd15`, `sd21`, `sdxl`, `sdxl-turbo`) bail
    /// loud at dispatch — SD UNet runtime LoRA infrastructure is
    /// deferred (v0.15 phase 7b-6 skeleton).
    #[serde(default)]
    loras: Vec<String>,

    /// v0.15 phase 7b-7: per-task LoRA scale multiplier applied on
    /// top of each LoRA's own `:weight` suffix. `None` defaults to
    /// 1.0. Mirrors `--lora-scale` on the CLI.
    #[serde(rename = "lora-scale", default)]
    lora_scale: Option<f32>,
}

/// v0.15 phase 7a: per-task enhancement override. Accepts a string
/// provider name (`"deepseek"`) or `false` to opt out.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum EnhanceCfg {
    Provider(String),
    Toggle(bool),
}

/// v0.15 phase 7a: per-task tiled override. Accepts a full config
/// block or `false` to force off.
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(untagged)]
enum TaskTiledCfg {
    Toggle(bool),
    Override(TiledCfg),
}

/// v0.13 phase 11: outpaint task block. At least one of the four
/// per-side fields (or `expand`) must be > 0. `expand` is shorthand
/// for setting all four sides equally and conflicts with the per-
/// side fields.
#[derive(Debug, Clone, Copy, Deserialize)]
struct OutpaintSpec {
    #[serde(default)]
    left: u32,
    #[serde(default)]
    right: u32,
    #[serde(default)]
    top: u32,
    #[serde(default)]
    bottom: u32,
    /// Shorthand: extend all four sides by this many pixels. When
    /// set, the per-side fields must all be 0.
    #[serde(default)]
    expand: Option<u32>,
}

/// v0.13 phase 11: helper trait — collapse `control:` (singular) and
/// `controls:` (Vec) into a single Vec<&ControlSpec>. Validates
/// mutual exclusion. Returned references live as long as the
/// borrowed task.
fn task_effective_controls(task: &TaskDef) -> Result<Vec<&ControlSpec>> {
    match (task.control.as_ref(), task.controls.as_ref()) {
        (Some(_), Some(_)) => bail!(
            "task {:?}: `control:` (singular) and `controls:` (Vec) are mutually exclusive — pick one form",
            task.name
        ),
        (Some(c), None) => Ok(vec![c]),
        (None, Some(v)) => {
            if v.is_empty() {
                bail!(
                    "task {:?}: `controls: []` is empty — drop the field or supply at least one entry",
                    task.name
                );
            }
            Ok(v.iter().collect())
        }
        (None, None) => Ok(Vec::new()),
    }
}

/// v0.13 phase 11: validate the inpaint / outpaint input combination
/// for a task. Returns the effective `(init_image, mask)` pair after
/// honouring `outpaint:` (which synthesises the mask itself). The
/// mask path returned for outpaint is `None` — the outpaint dispatch
/// generates it from the canvas + padding at run time.
fn task_validate_image_inputs(task: &TaskDef) -> Result<()> {
    if let Some(ospec) = task.outpaint.as_ref() {
        if task.init_image.is_none() {
            bail!(
                "task {:?}: `outpaint:` requires `init-image:` (the canvas to extend)",
                task.name
            );
        }
        if task.mask.is_some() {
            bail!(
                "task {:?}: `outpaint:` synthesises the mask — `mask:` must be unset",
                task.name
            );
        }
        // Touch ospec so the borrow stays around for the bail() above.
        let _ = ospec;
    }
    if task.mask.is_some() && task.init_image.is_none() {
        bail!(
            "task {:?}: `mask:` requires `init-image:` (mask without init-image is meaningless)",
            task.name
        );
    }
    Ok(())
}

impl OutpaintSpec {
    /// Resolve the four per-side amounts after applying `expand` and
    /// validate that at least one side is > 0.
    fn resolved(self) -> Result<(u32, u32, u32, u32)> {
        let (l, r, t, b) = match self.expand {
            Some(n) => {
                if self.left != 0 || self.right != 0 || self.top != 0 || self.bottom != 0 {
                    bail!(
                        "outpaint: `expand:` conflicts with per-side amounts; pick one form"
                    );
                }
                (n, n, n, n)
            }
            None => (self.left, self.right, self.top, self.bottom),
        };
        if l == 0 && r == 0 && t == 0 && b == 0 {
            bail!(
                "outpaint: need at least one of left/right/top/bottom (or expand) > 0"
            );
        }
        Ok((l, r, t, b))
    }
}

/// v0.13 phase 10: scenario-level tiled-denoise config. `size` and
/// `stride` are in **pixels**; the pipelines internally divide by the
/// VAE downsample (8) to get latent units. Same defaults as the CLI
/// flags (1024 / 768) when fields are omitted.
#[derive(Debug, Clone, Copy, Deserialize)]
struct TiledCfg {
    #[serde(default = "default_tile_size")]
    size: u32,
    #[serde(default = "default_tile_stride")]
    stride: u32,
}

fn default_tile_size() -> u32 {
    1024
}
fn default_tile_stride() -> u32 {
    768
}

impl From<TiledCfg> for crate::pipelines::tiled::TiledConfig {
    fn from(c: TiledCfg) -> Self {
        Self {
            tile_size: c.size,
            stride: c.stride,
        }
    }
}

/// Per-task ControlNet configuration. `kind` is a string parsed via
/// the same `FromStr` impl as the CLI `--control`. Conditioning
/// source is either `image` (pre-rendered map) or `auto-from`
/// (image to auto-annotate via the matching annotator). Exactly
/// one must be set. `strength` defaults to 1.0 when omitted.
#[derive(Debug, Clone, Deserialize)]
struct ControlSpec {
    kind: String,
    /// Pre-rendered conditioning image. Mutually exclusive with `auto-from`.
    #[serde(default)]
    image: Option<PathBuf>,
    /// **v0.10**: source image to auto-annotate. Mutually exclusive with `image`.
    #[serde(default, rename = "auto-from")]
    auto_from: Option<PathBuf>,
    #[serde(default)]
    strength: Option<f32>,
    /// Timestep window. `start` defaults to 0.0 when omitted; `end`
    /// defaults to 1.0. Set `end: 0.5` to lock composition early
    /// then release the prompt to drive late texture / atmosphere
    /// refinement.
    #[serde(default)]
    start: Option<f32>,
    #[serde(default)]
    end: Option<f32>,
}

/// One persona reference inside a task. Accepts both the Phase-1
/// bare-name form and the Phase-2 `{name, bbox}` form.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PersonaRef {
    /// `personas: [ alice ]` — single persona over the whole image.
    /// Errors at load when used alongside the bbox form or when `>1` of
    /// these appear in the same task.
    Bare(String),
    /// `personas: [ { name: alice, bbox: [x0,y0,x1,y1] } ]`. Multi-persona
    /// compositing path; allowed even with a single persona (works as a
    /// single-region inpaint, useful for fine framing control).
    Bbox(PersonaBboxRef),
}

#[derive(Debug, Deserialize)]
struct PersonaBboxRef {
    name: String,
    /// `[x0, y0, x1, y1]`. Validated at load: components in `[0, 1]`,
    /// `x0 < x1`, `y0 < y1`. Pixel-space coordinates are derived from
    /// the task's effective `(width, height)` at dispatch time.
    bbox: [f32; 4],
}

impl PersonaRef {
    fn name(&self) -> &str {
        match self {
            Self::Bare(n) => n,
            Self::Bbox(b) => &b.name,
        }
    }
    fn bbox(&self) -> Option<[f32; 4]> {
        match self {
            Self::Bare(_) => None,
            Self::Bbox(b) => Some(b.bbox),
        }
    }
}

pub async fn run(args: ScenarioArgs) -> Result<()> {
    let text = std::fs::read_to_string(&args.file)
        .with_context(|| format!("reading {}", args.file.display()))?;
    let s: ScenarioFile = deser_hjson::from_str(&text)
        .with_context(|| format!("parsing HJSON {}", args.file.display()))?;

    // -------- validate structure --------
    if s.tasks.is_empty() {
        bail!("scenario has no `tasks` to run");
    }
    let scenes: HashMap<&str, &str> = s
        .scene
        .iter()
        .map(|p| (p.name.as_str(), p.prompt.as_str()))
        .collect();
    let weathers: HashMap<&str, &str> = s
        .weather
        .iter()
        .map(|p| (p.name.as_str(), p.prompt.as_str()))
        .collect();
    for t in &s.tasks {
        if !scenes.contains_key(t.scene.as_str()) {
            bail!("task {:?} references unknown scene {:?}", t.name, t.scene);
        }
        if !weathers.contains_key(t.weather.as_str()) {
            bail!("task {:?} references unknown weather {:?}", t.name, t.weather);
        }
    }

    // -------- personas: validate + index by name --------
    // Build a name → PersonaDef map and pre-flight every field that could
    // fail later (identity-kind parse, photo existence) so the scenario
    // fails before the model load.
    let personas_map: BTreeMap<&str, &PersonaDef> = {
        let mut map: BTreeMap<&str, &PersonaDef> = BTreeMap::new();
        for p in &s.personas {
            if map.contains_key(p.name.as_str()) {
                bail!("duplicate persona name {:?}", p.name);
            }
            // Resolve photo / photos → normalized weighted list. This
            // also enforces mutual-exclusion + "at least one photo set".
            let resolved = p.resolve_photos()?;
            // Verify every referenced photo exists. For multi-photo
            // personas this lets us surface a missing path before the
            // long model load.
            for wp in &resolved {
                if !wp.path.exists() {
                    bail!(
                        "persona {:?}: photo not found at {}",
                        p.name,
                        wp.path.display()
                    );
                }
            }
            if let Some(id) = p.identity.as_deref() {
                // Parse just to validate; the parsed value is recomputed at
                // dispatch time so the persona record stays as written.
                let _: IdentityKind = id
                    .parse()
                    .with_context(|| format!("persona {:?} identity", p.name))?;
            }
            if let Some([x0, y0, x1, y1]) = p.face_bbox {
                let in_unit = (0.0..=1.0).contains(&x0)
                    && (0.0..=1.0).contains(&y0)
                    && (0.0..=1.0).contains(&x1)
                    && (0.0..=1.0).contains(&y1);
                if !in_unit || x0 >= x1 || y0 >= y1 {
                    bail!(
                        "persona {:?} face-bbox {:?} is invalid \
                         (must be [x0,y0,x1,y1] with 0 ≤ x0 < x1 ≤ 1 \
                         and 0 ≤ y0 < y1 ≤ 1)",
                        p.name,
                        p.face_bbox.unwrap(),
                    );
                }
            }
            if let Some(lm) = p.face_landmarks {
                for (i, [x, y]) in lm.iter().enumerate() {
                    if !(0.0..=1.0).contains(x) || !(0.0..=1.0).contains(y) {
                        bail!(
                            "persona {:?} face-landmarks point {} = [{}, {}] \
                             is out of range [0, 1]",
                            p.name,
                            i,
                            x,
                            y
                        );
                    }
                }
            }
            map.insert(p.name.as_str(), p);
        }
        map
    };

    // Validate task → persona references and enforce form-mixing rules.
    for t in &s.tasks {
        if let Some(refs) = &t.personas {
            // Resolve every name first.
            for r in refs {
                if !personas_map.contains_key(r.name()) {
                    let known: Vec<&str> = personas_map.keys().copied().collect();
                    bail!(
                        "task {:?} references unknown persona {:?} (defined: [{}])",
                        t.name,
                        r.name(),
                        known.join(", ")
                    );
                }
            }
            // Validate bbox bounds on every Bbox variant.
            for r in refs {
                if let Some([x0, y0, x1, y1]) = r.bbox() {
                    let inside_unit = (0.0..=1.0).contains(&x0)
                        && (0.0..=1.0).contains(&y0)
                        && (0.0..=1.0).contains(&x1)
                        && (0.0..=1.0).contains(&y1);
                    if !inside_unit || x0 >= x1 || y0 >= y1 {
                        bail!(
                            "task {:?}: persona {:?} bbox {:?} is invalid \
                             (must be [x0,y0,x1,y1] with 0 ≤ x0 < x1 ≤ 1 \
                             and 0 ≤ y0 < y1 ≤ 1)",
                            t.name,
                            r.name(),
                            r.bbox().unwrap(),
                        );
                    }
                }
            }
            // Form-mixing rule: within a single task, every entry must
            // use the bare-name form OR every entry must use the bbox
            // form. No mixing.
            let any_bare = refs.iter().any(|r| matches!(r, PersonaRef::Bare(_)));
            let any_bbox = refs.iter().any(|r| matches!(r, PersonaRef::Bbox(_)));
            if any_bare && any_bbox {
                bail!(
                    "task {:?}: cannot mix bare-name form (`[alice]`) with \
                     bbox form (`[{{name:alice, bbox:[...]}}]`) in the same \
                     task. Pick one. Use bbox for multi-persona compositing; \
                     use bare-name when the persona occupies the whole image.",
                    t.name
                );
            }
            // Bare-form: still capped at 1 (Phase-2 multi-persona requires
            // bboxes; bare-form `[alice, bob]` has no way to place them).
            if any_bare && refs.len() > 1 {
                let names: Vec<&str> = refs.iter().map(|r| r.name()).collect();
                bail!(
                    "task {:?} requests {} personas ({}) in bare-name form. \
                     Multi-persona requires bboxes — convert to \
                     `[{{name:..., bbox:[x0,y0,x1,y1]}}, ...]` form.",
                    t.name,
                    refs.len(),
                    names.join(", "),
                );
            }
        }
    }

    // -------- personas: agree on a single identity strategy --------
    // The portrait pipeline picks its base model (SD 1.5 / SDXL) from the
    // identity strategy used by the personas referenced in this scenario.
    // Mixed-variant scenarios would require loading two portrait pipelines
    // simultaneously (~10 GB+ of resident weights) and aren't supported.
    // Computed once here so the preload below can use it; also used to
    // surface a clear error if the user accidentally mixes strategies.
    let persona_kinds: HashMap<IdentityKind, Vec<&str>> = {
        let mut m: HashMap<IdentityKind, Vec<&str>> = HashMap::new();
        for p in &s.personas {
            let kind: IdentityKind = p
                .identity
                .as_deref()
                .unwrap_or("plus-face")
                .parse()
                .with_context(|| format!("persona {:?} identity", p.name))?;
            m.entry(kind).or_default().push(p.name.as_str());
        }
        m
    };
    let portrait_identity: Option<IdentityKind> = match persona_kinds.len() {
        0 => None,
        1 => Some(*persona_kinds.keys().next().unwrap()),
        _ => {
            let mix: Vec<String> = persona_kinds
                .iter()
                .map(|(k, names)| format!("{k:?} ({})", names.join(", ")))
                .collect();
            bail!(
                "scenario mixes identity strategies across personas: {}. \
                 Pick one strategy per scenario — every persona must share the \
                 same model variant (all SD 1.5 `plus-face`, or all SDXL \
                 `plus-face-sdxl`).",
                mix.join("; "),
            );
        }
    };

    let enhancer = s
        .enhancer
        .clone()
        .ok_or_else(|| anyhow!("scenario requires `enhancer` (deepseek | gemini)"))?;
    validate_enhancer_keys(&enhancer)?;

    // Parse the upscale method now so a bad string fails fast.
    let upscale_method: UpscaleMethod = s
        .upscale
        .method
        .parse()
        .with_context(|| format!("upscale.method = {:?}", s.upscale.method))?;

    // -------- resolve global parameters --------
    let model = s.model.clone().unwrap_or_else(|| "sd15".to_string());
    let device = crate::device::select(s.device.as_deref().unwrap_or("auto"))?;
    let base = s.base.unwrap_or(768);
    let count = s.count.unwrap_or(1);
    // `steps` / `guidance` start at the user's scenario values; the
    // v0.15 phase 7a `fast:` preset application below may override
    // them when the user didn't explicitly set them (i.e. left them
    // at plakat's documented defaults 28 / 7.5).
    let mut steps = s.steps.unwrap_or(28);
    let mut guidance = s.guidance.unwrap_or(7.5);
    let seed = s.seed.unwrap_or(0);
    let out_root = s.out.clone().unwrap_or_else(|| PathBuf::from("./out"));
    let lora_scale = s.lora_scale.unwrap_or(1.0);
    let refine_strength = s.refine_strength.unwrap_or(0.3);
    let scheduler: SchedulerKind = match s.scheduler.as_deref() {
        Some(x) => x.parse().with_context(|| format!("scheduler {x:?}"))?,
        None => SchedulerKind::Default,
    };

    let size = match s.size.as_deref() {
        Some(s) => Some(s.parse::<Size>().with_context(|| format!("size {s:?}"))?),
        None => None,
    };
    let (width, height) = crate::imaging::sizes::resolve(size, s.aspect.as_deref(), base)?;

    let mut loras: Vec<LoraSpec> = s
        .loras
        .iter()
        .map(|x| x.parse::<LoraSpec>())
        .collect::<Result<Vec<_>>>()?;

    // -------- v0.15 phase 7a: scenario-level `fast:` preset --------
    // Mirrors `plakat generate --fast PRESET` semantics:
    // * Prepend the preset's distillation LoRA to the scenario LoRA stack
    //   (so user-supplied LoRAs land later and can override on collision)
    // * Override `steps` / `guidance` only when user left them at the
    //   plakat defaults (28 / 7.5).
    // Per-task `fast:` is allowed iff equal to scenario-level — divergent
    // per-task presets need v0.15 phase 7b runtime LoRA.
    if let Some(name) = s.fast.as_deref() {
        let preset_arg: crate::pipelines::flux_fast::FastPresetArg = name
            .parse()
            .with_context(|| format!("scenario fast preset {name:?}"))?;
        let preset = preset_arg.0;
        let m = model.to_lowercase();
        if !m.contains("flux") {
            anyhow::bail!(
                "scenario `fast: {}` requires a Flux model (got model {:?}). \
                 Hyper-FLUX / FLUX-Turbo presets are Flux-family only.",
                preset.name, model
            );
        }
        if m.contains("fill") {
            anyhow::bail!(
                "scenario `fast: {}` doesn't compose with flux-fill-dev — Fill \
                 needs its own forward path.",
                preset.name
            );
        }
        // v0.15 phase 1 unblocked NF4 + LoRA + CN; fast (LoRA-based)
        // composes too. No NF4 bail here, unlike pre-v0.15 generate.rs.
        loras.insert(0, preset.to_lora_spec());
        if s.steps.is_none() {
            steps = preset.steps;
        }
        if s.guidance.is_none() {
            guidance = preset.guidance;
        }
        tracing::info!(
            target: "plakat",
            "scenario fast preset '{}': +{} LoRA, steps={steps}, guidance={guidance}",
            preset.name, preset.lora_repo
        );
        // Per-task `fast:` validation: must match scenario when both
        // are set, and must NOT be set per-task when scenario isn't
        // (the load-once design can't swap presets mid-run).
        for task in &s.tasks {
            if let Some(task_fast) = task.fast.as_deref() {
                if task_fast != name {
                    anyhow::bail!(
                        "task {:?} declares `fast: {task_fast}` but scenario uses \
                         `fast: {name}` — per-task preset swaps require runtime \
                         LoRA (deferred to v0.15 phase 7b). For now, all tasks must \
                         share the scenario preset.",
                        task.name
                    );
                }
            }
        }
    } else {
        // No scenario-level fast — reject per-task fast too. The
        // pre-loaded SdCore/Flux backbone doesn't have the preset's
        // distillation LoRA merged, so applying just the
        // step/guidance overrides per task would silently produce
        // garbage outputs.
        for task in &s.tasks {
            if let Some(task_fast) = task.fast.as_deref() {
                anyhow::bail!(
                    "task {:?} declares `fast: {task_fast}` but scenario has no \
                     scenario-level `fast:` — promote it to the scenario level so \
                     the preset's distillation LoRA is loaded into the pipeline.",
                    task.name
                );
            }
        }
    }

    // -------- v0.13 phase 11: validate per-task fields --------
    // Surface schema errors (mutually-exclusive control/controls,
    // outpaint without init-image, etc.) before we burn time on model
    // loads. Same loop applies the validators across every task.
    for task in &s.tasks {
        task_effective_controls(task).with_context(|| {
            format!("validating ControlNet config for task {:?}", task.name)
        })?;
        task_validate_image_inputs(task).with_context(|| {
            format!("validating image inputs for task {:?}", task.name)
        })?;
    }

    // -------- style detection / transfer --------
    // A single `StyleSession` is constructed when any task (or the
    // scenario globally) uses style detection. The session shares one
    // CLIP-H encoder load across global + every per-task style-ref;
    // a 5-task scenario where every task has its own style-ref pays
    // for the ~2.5 GB encoder weights exactly once.
    let any_task_style = s.tasks.iter().any(|t| t.style_ref_catalog.is_some());
    let scenario_uses_style = s.style_ref.is_some() || s.style.is_some() || any_task_style;
    let mut style_session: Option<StyleSession> = if scenario_uses_style {
        Some(StyleSession::load(s.style_catalog.as_deref(), device.clone())?)
    } else {
        None
    };

    // Global style block — applied to all tasks that don't have their
    // own per-task style override.
    let mut effective_lora_header = s.lora_header.clone();
    let mut effective_negative = s.negative.clone();
    if s.style_ref.is_some() || s.style.is_some() {
        let n_user_loras = loras.len();
        let session = style_session.as_mut().expect("session created when any style is set");
        let prep = session
            .prepare(StylePrepRequest {
                style_ref: s.style_ref.as_deref(),
                style_override: s.style.as_deref(),
                style_strength: s.style_strength.unwrap_or(1.0),
                style_catalog: None, // session already locked the catalog in
                model: &model,
                user_loras_nonempty: !loras.is_empty(),
                device: &device,
            })
            .await?;

        log_style_prep(&prep, n_user_loras);

        loras = parse_resolved_loras(&prep)?;
        effective_lora_header = prepend_trigger(&prep.trigger, &effective_lora_header);
        effective_negative = combine_negative(&effective_negative, &prep.negative_extras);
    }

    // v0.16 phase 11: preflight check — when running SD-family with
    // per-task LoRA stacks, surface the hint NOW rather than at the
    // first task's apply_loras bail. Includes the "all per-task
    // LoRAs identical → fold into scenario.loras" suggestion when
    // applicable, with the exact YAML to copy.
    sd_per_task_lora_preflight(&s, &model)?;

    // -------- execution plan summary --------
    let total_images = (s.tasks.len() as u32) * count;
    println!(
        "{}  {} task(s) × {} image(s) = {} image(s) to generate",
        style("scenario").yellow().bold(),
        s.tasks.len(),
        count,
        total_images,
    );
    println!("  model:     {model}");
    println!("  size:      {width}×{height}");
    println!("  steps:     {steps}  guidance: {guidance}  scheduler: {scheduler:?}");
    println!("  out:       {}", out_root.display());
    println!("  enhancer:  {enhancer}");
    if !loras.is_empty() {
        println!("  loras:     {} (scale {lora_scale})", loras.len());
    }
    if let Some(r) = s.refine {
        println!("  refine:    {r} steps × strength {refine_strength}");
    }
    if s.refiner {
        let frac = s.refiner_frac.unwrap_or(0.8);
        println!(
            "  refiner:   on (switch at {:.0}% of schedule, SDXL only)",
            frac * 100.0
        );
    }
    if s.upscale.upscale {
        let shown = upscale_method.native_scale().unwrap_or(s.upscale.scale);
        println!(
            "  upscale:   {:.2}× {} (post-stylize if `style` is set, else original)",
            shown, s.upscale.method
        );
    }
    // v0.13 phase 10: print effective tiled / GGUF settings when set.
    if let Some(tcfg) = s.tiled {
        println!(
            "  tiled:     {}px tiles, stride {}px (MultiDiffusion)",
            tcfg.size, tcfg.stride
        );
    }
    if s.quantize_t5
        || s.quant_level.is_some()
        || s.t5_quant_level.is_some()
    {
        let q = s.quant_level.as_deref().unwrap_or("Q4_K_S");
        if s.quantize_t5 {
            let t5q = s.t5_quant_level.as_deref().unwrap_or("Q4_K_M");
            println!("  gguf:      Flux={q}, T5={t5q} (quantized T5)");
        } else {
            println!("  gguf:      Flux={q} (T5 stays BF16)");
        }
    }
    if !s.personas.is_empty() {
        let names: Vec<&str> = s.personas.iter().map(|p| p.name.as_str()).collect();
        let persona_tasks = s
            .tasks
            .iter()
            .filter(|t| t.personas.as_deref().map(|p| !p.is_empty()).unwrap_or(false))
            .count();
        let portrait_label = portrait_identity
            .map(|k| k.label())
            .unwrap_or("(unused — no persona tasks)");
        println!(
            "  personas:  {} defined [{}], used by {} task(s) — {}",
            s.personas.len(),
            names.join(", "),
            persona_tasks,
            portrait_label,
        );
    }

    if !args.dry_run {
        std::fs::create_dir_all(&out_root)?;
    }

    // -------- preload the Real-ESRGAN model if used --------
    // Without this, every task would re-download + re-build the model.
    let esrgan: Option<EsrganPipeline> =
        if !args.dry_run && s.upscale.upscale && upscale_method.is_ml() {
            Some(EsrganPipeline::load(upscale_method, &device).await?)
        } else {
            None
        };

    // -------- load pipeline once (skipped for dry-run) --------
    // Three parallel pipeline types; exactly one is populated for
    // non-dry-run runs (SD-family / Flux / SD3-family).
    let variant = Variant::detect(&model);
    let pipeline: Option<Pipeline> = if args.dry_run || variant.is_flux() || variant.is_sd3() {
        None
    } else {
        Some(
            Pipeline::load(LoadRequest {
                model: model.clone(),
                device: device.clone(),
                loras: loras.clone(),
                lora_scale,
                use_refiner: s.refiner,
                // v0.16 phase 9: scenarios don't surface --embedding
                // (TI runtime injection is still gated by candle).
                embeddings: Vec::new(),
            })
            .await?,
        )
    };
    // v0.16 phase 2: SD3 / SD3.5 backbone loaded once for the whole
    // scenario (previously the scenario fell through to t2i::Pipeline
    // which bailed on SD3). Per-task LoRA dispatches via
    // `sd3::Pipeline::apply_loras` between tasks.
    let mut sd3_pipeline: Option<crate::pipelines::sd3::Pipeline> =
        if args.dry_run || !variant.is_sd3() {
            None
        } else {
            use crate::pipelines::sd3;
            let sd3_variant = match variant {
                Variant::Sd3Medium => sd3::Variant::Sd3Medium,
                Variant::Sd35Medium => sd3::Variant::Sd35Medium,
                Variant::Sd35Large => sd3::Variant::Sd35Large,
                Variant::Sd35LargeTurbo => sd3::Variant::Sd35LargeTurbo,
                _ => unreachable!("is_sd3() implies one of the SD3 variants"),
            };
            let resolved_repo = if model.contains('/') {
                model.clone()
            } else {
                crate::hf::resolve_alias(&model).to_string()
            };
            Some(
                sd3::Pipeline::load(sd3::LoadRequest {
                    variant: sd3_variant,
                    repo: resolved_repo,
                    device: device.clone(),
                    loras: loras.clone(),
                    lora_scale,
                    // SD3 ControlNet wiring into scenarios lands in
                    // a later phase. For now scenarios load with no
                    // SD3 CN slots; the per-task CN spec dispatch
                    // mirrors what the existing Flux scenario path
                    // does (max_flux_controls + scenario-wide
                    // preload).
                    controlnets: Vec::new(),
                })
                .await?,
            )
        };
    // -------- preload the stylize pipeline if any task uses `style` --------
    let any_style = s.tasks.iter().any(|t| t.style.is_some());

    // -------- preload the portrait pipeline if any task uses `personas` ----
    let any_persona = s
        .tasks
        .iter()
        .any(|t| t.personas.as_deref().map(|v| !v.is_empty()).unwrap_or(false));

    // Phase 7f: pre-load a single CLIP-H image encoder when both
    // stylize and a Plus-Face portrait identity are going to run.
    // FaceID strategies don't touch CLIP-H, so they don't trigger the
    // share. The shared Arc is then fed into both pipelines' load
    // requests so each skips its own download / mmap.
    let plusface_portrait = any_persona
        && matches!(
            portrait_identity,
            Some(IdentityKind::PlusFace) | Some(IdentityKind::PlusFaceSdxl)
        );
    let shared_clip_h: Option<std::sync::Arc<crate::pipelines::ip_adapter::ImageEncoder>> =
        if !args.dry_run && any_style && plusface_portrait {
            let spinner = crate::ui::progress::spinner(
                "Pre-loading shared CLIP-H image encoder",
            );
            // F32 matches stylize's standalone choice and the portrait
            // identity encoder casts down at encode-time as needed.
            let arc = crate::pipelines::ip_adapter::load_shared_clip_vision(
                &device,
                candle_core::DType::F32,
            )
            .await?;
            spinner.finish_with_message("✓ shared CLIP-H loaded");
            Some(arc)
        } else {
            None
        };

    let stylize_pipeline: Option<stylize::Pipeline> = if !args.dry_run && any_style {
        // stylize is SD 1.5 only — the IP-Adapter projection targets the SD 1.5
        // cross-attention dim (768). The scenario's main `model` can still be
        // anything (we operate on the produced image bytes, not on latents).
        Some(
            stylize::Pipeline::load(stylize::LoadRequest {
                model: "sd15".to_string(),
                device: device.clone(),
                shared_clip_h: shared_clip_h.clone(),
            })
            .await?,
        )
    } else {
        None
    };

    let portrait_pipeline: Option<portrait::Pipeline> = if !args.dry_run && any_persona {
        // Portrait base model is derived from the scenario's persona
        // identity kind (all personas must agree — validated above). The
        // scenario's main `model` field stays separate: non-persona tasks
        // use that; persona tasks use this portrait pipeline.
        let kind = portrait_identity
            .expect("any_persona implies at least one persona, validation ensures kind agreement");
        let portrait_model = kind.target_variant().to_string();
        Some(
            portrait::Pipeline::load(portrait::LoadRequest {
                model: portrait_model,
                device: device.clone(),
                loras: loras.clone(),
                lora_scale,
                identity: Some(kind),
                // Only Plus-Face identity strategies consume CLIP-H;
                // FaceID strategies ignore this even when set.
                shared_clip_h: shared_clip_h.clone(),
            })
            .await?,
        )
    } else {
        None
    };

    let mut flux_pipeline: Option<flux::Pipeline> = if args.dry_run || !variant.is_flux() {
        None
    } else {
        if !loras.is_empty() {
            crate::ui::progress::println(&format!(
                "  {} ignoring {} LoRA file(s): SD-format LoRAs don't apply to Flux's transformer",
                style("warn:").yellow().bold(),
                loras.len()
            ));
        }
        let fvar = match variant {
            Variant::FluxDev => flux::Variant::Dev,
            Variant::FluxFillDev => flux::Variant::FillDev,
            _ => flux::Variant::Schnell,
        };
        let resolved_repo = if model.contains('/') {
            model.clone()
        } else {
            crate::hf::resolve_alias(&model).to_string()
        };
        // v0.12: Flux LoRAs (PEFT-format) are now supported via
        // flux_lora::merge_flux_loras_into_weights. Resolve the
        // scenario's `loras` here so the merge runs at load time.
        let resolved_flux_loras: Vec<crate::pipelines::lora::ResolvedLora> =
            if loras.is_empty() {
                Vec::new()
            } else {
                let mut v = Vec::with_capacity(loras.len());
                for spec in &loras {
                    v.push(spec.resolve().await?);
                }
                v
            };
        // v0.13 phase 10: pre-load Shakker-Labs Union Pro v2 once if
        // any task in the scenario uses `control:`. Union Pro v2 covers
        // canny/softedge/openpose/depth/lineart via a single weight set
        // — `set_controlnet_call_params` swaps the mode + scale per
        // task. Tasks that have no `control:` simply leave the CN's
        // conditioning empty and it contributes no residuals.
        // v0.13 phase 11: scenario-wide max CN slots = the largest
        // `effective_controls().len()` across all tasks. Load that
        // many Union Pro v2 instances at startup so each per-task
        // multi-CN dispatch has independent slots to mutate. One slot
        // is enough for the common single-CN case; the cost scales
        // linearly with the deepest stack any task asks for.
        let max_flux_controls = s
            .tasks
            .iter()
            .map(|t| task_effective_controls(t).map(|v| v.len()).unwrap_or(0))
            .max()
            .unwrap_or(0);
        let flux_controlnets: Vec<flux::FluxControlNetLoad> = if max_flux_controls > 0 {
            use crate::pipelines::flux_controlnet;
            (0..max_flux_controls)
                .map(|_| flux::FluxControlNetLoad {
                    repo: "Shakker-Labs/FLUX.1-dev-ControlNet-Union-Pro-2.0".to_string(),
                    file: "diffusion_pytorch_model.safetensors".to_string(),
                    cfg: flux_controlnet::Config::shakker_union_pro_v2(),
                    // Per-task: mutated via `set_controlnet_call_params`
                    // / `set_controlnet_conditioning` below.
                    scale: 1.0,
                    mode: None,
                    conditioning: None,
                    start: 0.0,
                    end: 1.0,
                })
                .collect()
        } else {
            Vec::new()
        };
        Some(
            flux::Pipeline::load(flux::LoadRequest {
                variant: fvar,
                repo: resolved_repo,
                device: device.clone(),
                loras: resolved_flux_loras,
                lora_scale,
                controlnets: flux_controlnets,
                // v0.13 phase 10: surface --quantize-t5 + GGUF quant
                // levels at scenario scope (load-time decisions).
                quantize_t5: s.quantize_t5,
                flux_quant_level: s.quant_level.clone(),
                t5_quant_level: s.t5_quant_level.clone(),
                // v0.14 phase 3c: enable the Redux encoder if ANY
                // task in the scenario uses `redux-images:`. Loaded
                // once at scenario startup, reused across tasks.
                redux: s.tasks.iter().any(|t| !t.redux_images.is_empty()),
            })
            .await?,
        )
    };

    // -------- enhance prompts up front, deduped + parallelized --------
    // Each task's `pre_refine` string is enhancer-input; under sequential
    // per-task calls this fires N times serially. Pre-loop we dedupe to the
    // set of unique prompts and fire them concurrently (capped), so a 10-task
    // scenario with one slow enhancer goes from ~10 * latency to
    // ~ceil(unique / cap) * latency. Dry-run skips this entirely.
    let enhanced_cache: HashMap<String, String> = if args.dry_run {
        HashMap::new()
    } else {
        // v0.15 phase 7a: skip pre_refines for tasks that opted out
        // via `enhance: false` — those won't consult the cache.
        let pre_refines: Vec<String> = s
            .tasks
            .iter()
            .filter(|t| !matches!(t.enhance, Some(EnhanceCfg::Toggle(false))))
            .map(|t| {
                let scene = scenes[t.scene.as_str()];
                let weather = weathers[t.weather.as_str()];
                join_parts(&[
                    &s.prompt_header,
                    scene,
                    weather,
                    &t.prompt,
                    &s.prompt_footer,
                ])
            })
            .collect();
        let unique: BTreeSet<String> = pre_refines.iter().cloned().collect();

        crate::ui::progress::println(&format!(
            "  {} enhancing {} unique prompt(s) (from {} task{}) via {}…",
            style("→").cyan().bold(),
            unique.len(),
            s.tasks.len(),
            if s.tasks.len() == 1 { "" } else { "s" },
            enhancer,
        ));

        // Soft cap on concurrent requests to be polite to upstream APIs.
        const MAX_CONCURRENT_ENHANCE: usize = 8;
        let sem = Arc::new(Semaphore::new(MAX_CONCURRENT_ENHANCE));
        let mut joinset: JoinSet<(String, Result<String>)> = JoinSet::new();
        for pre in unique {
            let enhancer_owned = enhancer.clone();
            let pre_owned = pre.clone();
            let sem = sem.clone();
            joinset.spawn(async move {
                let _permit = sem.acquire().await.expect("semaphore not closed");
                let result = crate::prompt::enhance(&enhancer_owned, &pre_owned).await;
                (pre_owned, result)
            });
        }

        let mut cache: HashMap<String, String> = HashMap::with_capacity(joinset.len());
        while let Some(joined) = joinset.join_next().await {
            let (pre, result) = joined.context("enhancer task panicked")?;
            let enhanced = result
                .with_context(|| format!("enhancing prompt {:?}", trim_preview(&pre, 80)))?;
            cache.insert(pre, enhanced);
        }
        cache
    };

    // -------- v3 smart-zones depth pipeline (lazy) --------
    // Load once on first need across the run. Tasks share it so the
    // ~99 MB Depth-Anything-V2 weights are mmap'd just once.
    let mut smart_depth: Option<crate::pipelines::depth::DepthPipeline> = None;
    let mut smart_depth_attempted = false;
    let any_smart = s.smart_zones
        || s.tasks
            .iter()
            .any(|t| t.smart_zones.unwrap_or(false));

    // -------- v0.9 ControlNet cache (lazy, keyed by kind) --------
    // Same lazy pattern as smart_depth. The first task that needs a
    // given ControlKind triggers a download; subsequent tasks reuse
    // the loaded network.
    let mut controlnets: std::collections::HashMap<
        crate::pipelines::controlnet::ControlKind,
        crate::pipelines::controlnet::ControlNet,
    > = std::collections::HashMap::new();
    let cn_dtype = if matches!(device, candle_core::Device::Cpu) {
        candle_core::DType::F32
    } else {
        candle_core::DType::F16
    };
    // v0.10: scenarios run a single model architecture for all
    // tasks, so the ControlNet variant is scenario-wide too.
    let cn_variant = crate::pipelines::controlnet::ControlNetVariant::detect(&model);

    // v0.19: --only filters tasks by name. Validate up-front so a
    // typo bails before the long batch starts. --limit caps the
    // run length post-filter.
    if !args.only.is_empty() {
        let scenario_names: std::collections::HashSet<&str> =
            s.tasks.iter().map(|t| t.name.as_str()).collect();
        for requested in &args.only {
            if !scenario_names.contains(requested.as_str()) {
                anyhow::bail!(
                    "--only {requested:?} not found in scenario {:?}. \
                     Available task names: {}",
                    args.file.display(),
                    s.tasks
                        .iter()
                        .map(|t| t.name.as_str())
                        .collect::<Vec<_>>()
                        .join(", "),
                );
            }
        }
    }
    let only_set: Option<std::collections::HashSet<&str>> = if args.only.is_empty() {
        None
    } else {
        Some(args.only.iter().map(|s| s.as_str()).collect())
    };

    // -------- main loop --------
    let mut seed_offset: u64 = 0;
    let mut ran_count: u32 = 0;
    for (idx, task) in s.tasks.iter().enumerate() {
        // v0.19: skip tasks excluded by --only / --limit. The
        // seed_offset advance still happens for skipped tasks so a
        // partial --only run yields the same seeds as the full
        // batch — important for reproducibility when iterating on
        // one task in isolation.
        if let Some(allowed) = &only_set {
            if !allowed.contains(task.name.as_str()) {
                seed_offset += count as u64;
                continue;
            }
        }
        if args.limit > 0 && ran_count >= args.limit {
            crate::ui::progress::println(&format!(
                "  {} reached --limit {} — skipping remaining tasks",
                style("(limit)").yellow(),
                args.limit,
            ));
            break;
        }
        ran_count += 1;

        let scene_prompt = scenes[task.scene.as_str()];
        let weather_prompt = weathers[task.weather.as_str()];

        let pre_refine = join_parts(&[
            &s.prompt_header,
            scene_prompt,
            weather_prompt,
            &task.prompt,
            &s.prompt_footer,
        ]);

        crate::ui::progress::println(&format!(
            "\n{} [{}/{}] {} (scene={}, weather={})",
            style("▶").cyan().bold(),
            idx + 1,
            s.tasks.len(),
            style(&task.name).bold(),
            task.scene,
            task.weather,
        ));
        crate::ui::progress::println(&wrap_label("pre-enhance", &pre_refine));

        // -------- per-task style override --------
        // Trigger + negative_extras only — the pipeline pre-loaded its
        // LoRAs at scenario start and can't swap them per task without
        // a full reload. If the per-task style would have wanted
        // different LoRAs, warn loudly so the user knows the LoRAs
        // aren't being applied (trigger phrase alone won't fully
        // produce a style that needs a LoRA).
        let task_overrides_style = task.style_ref_catalog.is_some();
        let task_lora_header: String;
        let task_negative_base: String;
        if task_overrides_style {
            let session = style_session
                .as_mut()
                .expect("session created when any style is set");
            let prep = session
                .prepare(StylePrepRequest {
                    style_ref: task.style_ref_catalog.as_deref(),
                    style_override: None,
                    style_strength: s.style_strength.unwrap_or(1.0),
                    style_catalog: None, // session already locked the catalog in
                    model: &model,
                    // For per-task, the warning's "N user LoRAs overridden"
                    // count is meaningless (global LoRAs are NEVER overridden
                    // per-task) — suppress by passing false.
                    user_loras_nonempty: false,
                    device: &device,
                })
                .await?;

            log_style_prep(&prep, 0);

            // Warn when per-task style would have wanted LoRAs that differ
            // from the global ones currently loaded.
            let task_resolved = parse_resolved_loras(&prep)?;
            if !same_lora_set(&task_resolved, &loras) {
                crate::ui::progress::println(&format!(
                    "  {} per-task style '{}' wants {} LoRA(s); scenarios share \
                     one pipeline so only trigger + negative apply (global LoRAs \
                     stay loaded)",
                    style("⚠").yellow(),
                    prep.picked_style_id,
                    task_resolved.len()
                ));
            }

            // Per-task style applies against the SCENARIO BASE values, not
            // against the global-style-modified ones. Symmetrical with how
            // global style applies against user-authored values.
            task_lora_header = prepend_trigger(&prep.trigger, &s.lora_header);
            task_negative_base = combine_negative(&s.negative, &prep.negative_extras);
        } else {
            task_lora_header = effective_lora_header.clone();
            task_negative_base = effective_negative.clone();
        }

        // v0.15 phase 7a: `enhance: false` on the task skips the
        // enhancement step for this task only — pre_refine carries
        // through unmodified. `enhance: "provider"` must match the
        // scenario-level enhancer (the cache is built once with the
        // scenario provider, so per-task swap requires a wider
        // refactor — deferred).
        let task_skip_enhance =
            matches!(task.enhance, Some(EnhanceCfg::Toggle(false)));
        if let Some(EnhanceCfg::Provider(p)) = task.enhance.as_ref() {
            if p != &enhancer {
                anyhow::bail!(
                    "task {:?}: enhance provider {p:?} differs from scenario \
                     `enhancer: {enhancer}` — per-task provider swap not yet wired. \
                     Use the scenario enhancer or drop the override.",
                    task.name
                );
            }
        }
        let enhanced = if args.dry_run {
            format!("(dry-run; {enhancer} not called)")
        } else if task_skip_enhance {
            pre_refine.clone()
        } else {
            enhanced_cache
                .get(&pre_refine)
                .cloned()
                .ok_or_else(|| {
                    anyhow!(
                        "task {:?}: enhanced prompt missing from cache (internal bug)",
                        task.name
                    )
                })?
        };
        crate::ui::progress::println(&wrap_label("enhanced", &enhanced));

        let mut final_prompt = join_parts(&[&task_lora_header, &enhanced, &s.lora_footer]);
        crate::ui::progress::println(&wrap_label("final", &final_prompt));

        if args.dry_run {
            // Show effective per-task values in dry-run so a user can see
            // what overrides are taking effect.
            let dry_count = task.count.unwrap_or(count);
            let dry_seed = task.seed.unwrap_or(seed + seed_offset);
            let dry_task_out = out_root.join(safe_name(&task.name));
            crate::ui::progress::println(&format!(
                "  {} would generate {} image(s) with seeds {}..{} → {}",
                style("(dry-run)").dim(),
                dry_count,
                dry_seed,
                dry_seed + dry_count as u64 - 1,
                dry_task_out.display(),
            ));
            if has_overrides(task) {
                crate::ui::progress::println(&format!(
                    "  {} overrides: {}",
                    style("(dry-run)").dim(),
                    describe_overrides(task)
                ));
            }
            // v0.25 phase 7: surface effective look/genre/offline so users
            // can validate that task / scenario fallbacks resolve to the
            // intended preset before paying the inference cost.
            let dry_look = task.look.as_ref().or(s.look.as_ref());
            let dry_genre = task.genre.as_ref().or(s.genre.as_ref());
            let dry_offline = task.offline.or(s.offline).unwrap_or(false);
            if dry_look.is_some() || dry_genre.is_some() {
                let parts = [
                    dry_look.map(|n| format!("look={n}")),
                    dry_genre.map(|n| format!("genre={n}")),
                    dry_offline.then(|| "offline=true".to_string()),
                ];
                let s_parts: Vec<String> = parts.into_iter().flatten().collect();
                crate::ui::progress::println(&format!(
                    "  {} presets: {}",
                    style("(dry-run)").dim(),
                    s_parts.join(", ")
                ));
            }
            if let Some(refs) = &task.personas {
                for r in refs {
                    let p = personas_map[r.name()];
                    let primary = p.primary_photo_path();
                    let exists = match &primary {
                        Some(path) if path.exists() => "ok",
                        Some(_) => "MISSING",
                        None => "(no photo set)",
                    };
                    let photo_desc = match &primary {
                        Some(path) => {
                            let multi = if !p.photos.is_empty() && p.photos.len() > 1 {
                                format!(" +{} more", p.photos.len() - 1)
                            } else {
                                String::new()
                            };
                            format!("{}{multi}", path.display())
                        }
                        None => String::from("(none)"),
                    };
                    let strength = p.face_strength.unwrap_or(0.8);
                    let bbox_str = match r.bbox() {
                        Some([x0, y0, x1, y1]) => {
                            format!(" bbox=[{x0:.2},{y0:.2},{x1:.2},{y1:.2}]")
                        }
                        None => String::new(),
                    };
                    crate::ui::progress::println(&format!(
                        "  {} would impose persona {:?} via portrait pipeline \
                         (photo {}, strength {:.2}{}, {})",
                        style("(dry-run)").dim(),
                        p.name,
                        photo_desc,
                        strength,
                        bbox_str,
                        exists,
                    ));
                }
            }
            if let Some(style_ref) = &task.style {
                let strength = task.style_strength.unwrap_or(0.6);
                let exists = if style_ref.exists() { "ok" } else { "MISSING" };
                crate::ui::progress::println(&format!(
                    "  {} would stylize each with REF {} (strength {:.2}, {})",
                    style("(dry-run)").dim(),
                    style_ref.display(),
                    strength,
                    exists,
                ));
            }
            if s.upscale.upscale {
                let target = if task.style.is_some() {
                    "styled"
                } else {
                    "original"
                };
                crate::ui::progress::println(&format!(
                    "  {} would upscale the {} image(s) at {:.2}× ({:?})",
                    style("(dry-run)").dim(),
                    target,
                    s.upscale.scale,
                    upscale_method,
                ));
            }
            seed_offset += count as u64;
            continue;
        }

        // -------- effective per-task values (override-or-global) --------
        // Resolution: per-task size/aspect override the global pair.
        let task_size = match task.size.as_deref() {
            Some(s) => Some(s.parse::<Size>().with_context(|| format!("task size {s:?}"))?),
            None => None,
        };
        let (mut eff_w, mut eff_h) = if task_size.is_some() || task.aspect.is_some() {
            crate::imaging::sizes::resolve(
                task_size,
                task.aspect.as_deref(),
                base,
            )?
        } else {
            (width, height)
        };

        // v0.13 phase 11: outpaint pre-processing. When `outpaint:` is
        // set, the canvas + mask are synthesised from `init-image:`
        // and the resolved per-side padding; the working resolution is
        // overridden to the expanded canvas size (the user's `size:`
        // is ignored — outpaint defines its own output dims). The
        // tempdir holds canvas.png + mask.png alive across the
        // generate dispatch.
        let mut eff_init_image: Option<PathBuf> = task.init_image.clone();
        let mut eff_mask: Option<PathBuf> = task.mask.clone();
        let _outpaint_tmp: Option<tempfile::TempDir> = if let Some(ospec) = task.outpaint {
            let init = task
                .init_image
                .as_ref()
                .expect("validated above (outpaint requires init-image)");
            let (l_req, r_req, t_req, b_req) = ospec.resolved()?;
            // Snap padding to the model's VAE / patch constraint —
            // mirrors `cli::outpaint::run`. Flux uses 16, SD uses 8.
            let snap: u32 = if model.to_lowercase().contains("flux") {
                16
            } else {
                8
            };
            let snap_up = |n: u32| if n == 0 { 0 } else { ((n + snap - 1) / snap) * snap };
            let (l, r, t, b) =
                (snap_up(l_req), snap_up(r_req), snap_up(t_req), snap_up(b_req));
            let input_img = image::open(init).with_context(|| {
                format!(
                    "task {:?}: opening outpaint init-image {}",
                    task.name,
                    init.display()
                )
            })?;
            let (in_w, in_h) = {
                use image::GenericImageView;
                input_img.dimensions()
            };
            if in_w % snap != 0 || in_h % snap != 0 {
                bail!(
                    "task {:?}: outpaint init-image is {in_w}x{in_h}, not divisible by {snap} \
                     (the model's VAE / patch constraint). Resize the input to a multiple of \
                     {snap} before outpainting.",
                    task.name
                );
            }
            let in_rgb = input_img.to_rgb8();
            let new_w = in_w + l + r;
            let new_h = in_h + t + b;
            let canvas =
                crate::cli::outpaint::build_replicate_canvas(&in_rgb, l, t, new_w, new_h);
            let mask = crate::cli::outpaint::build_outpaint_mask(in_w, in_h, l, t, new_w, new_h);
            let tmp = tempfile::Builder::new()
                .prefix("plakat-scenario-outpaint-")
                .tempdir()?;
            let cpath = tmp.path().join("canvas.png");
            let mpath = tmp.path().join("mask.png");
            canvas.save(&cpath).with_context(|| {
                format!("task {:?}: writing outpaint canvas", task.name)
            })?;
            mask.save(&mpath).with_context(|| {
                format!("task {:?}: writing outpaint mask", task.name)
            })?;
            eff_w = new_w;
            eff_h = new_h;
            eff_init_image = Some(cpath);
            eff_mask = Some(mpath);
            crate::ui::progress::println(&format!(
                "  outpaint: {in_w}x{in_h} → {new_w}x{new_h} (left={l} right={r} top={t} bottom={b}, snap={snap})"
            ));
            Some(tmp)
        } else {
            None
        };

        let eff_count = task.count.unwrap_or(count);
        let mut eff_steps = task.steps.unwrap_or(steps);
        let mut eff_guidance = task.guidance.unwrap_or(guidance);
        let mut eff_negative = task
            .negative
            .clone()
            .unwrap_or_else(|| task_negative_base.clone());
        let mut eff_scheduler: SchedulerKind = match task.scheduler.as_deref() {
            Some(s) => s.parse().with_context(|| format!("task scheduler {s:?}"))?,
            None => scheduler,
        };
        let eff_refine = task.refine.or(s.refine);
        let eff_refine_strength = task.refine_strength.unwrap_or(refine_strength);
        let eff_refiner_frac = task.refiner_frac.unwrap_or(s.refiner_frac.unwrap_or(0.8));

        // v0.15 phase 7a: scenario↔task sync resolutions. Task wins
        // when set; scenario provides the default; explicit `false`
        // on tiled / enhance forces off even if scenario has it on.
        let eff_concept_image: Option<PathBuf> = task
            .concept_image
            .clone()
            .or_else(|| s.concept_image.clone());
        // v0.18 Kontext phase 4: task override → scenario fallback →
        // false default. Only honoured at Pipeline::generate when the
        // resolved model is Kontext.
        let eff_kontext_bucket: bool =
            task.kontext_bucket.or(s.kontext_bucket).unwrap_or(false);
        let eff_tiled: Option<crate::pipelines::tiled::TiledConfig> = match &task.tiled {
            Some(TaskTiledCfg::Toggle(false)) => None,
            Some(TaskTiledCfg::Toggle(true)) => {
                // `tiled: true` without an explicit block at task scope:
                // inherit the scenario-level config, or fall back to
                // the same defaults the CLI uses (1024 / 768).
                s.tiled.map(Into::into).or(Some(
                    crate::pipelines::tiled::TiledConfig {
                        tile_size: default_tile_size(),
                        stride: default_tile_stride(),
                    },
                ))
            }
            Some(TaskTiledCfg::Override(cfg)) => Some((*cfg).into()),
            None => s.tiled.map(Into::into),
        };
        // `task.enhance` is checked directly at the per-task enhance
        // lookup below — `Toggle(false)` skips the cache, `Provider(p)`
        // must equal scenario.enhancer (validated below).
        //
        // `task.fast` is validated at scenario load (must equal
        // scenario.fast — true per-task swap is v0.15 phase 7b runtime
        // LoRA), so no per-task resolution is needed here.

        // v0.25 phase 7: --look / --genre presets in scenarios. Task
        // wins; scenario provides the default. Auto-LoRA discovery is
        // NOT wired here (the scenario's two-stage LoRA pipeline
        // makes integration non-trivial); the prompt prefix/suffix +
        // sampler hints apply, and users who want a specific LoRA
        // pass `loras:` at the scenario or task level.
        //
        // Override-only-if-user-didn't-pass: if scenario OR task
        // explicitly sets steps / guidance / scheduler, that wins
        // over the preset's recommendation.
        let eff_look = task.look.clone().or_else(|| s.look.clone());
        let eff_genre = task.genre.clone().or_else(|| s.genre.clone());
        let _eff_offline = task.offline.or(s.offline).unwrap_or(false);
        if eff_look.is_some() || eff_genre.is_some() {
            use crate::preset::{GenerationParams, apply_presets};
            use std::str::FromStr;

            let user_set_steps = task.steps.is_some() || s.steps.is_some();
            let user_set_guidance = task.guidance.is_some() || s.guidance.is_some();
            let user_set_scheduler =
                task.scheduler.is_some() || s.scheduler.is_some();

            let mut params = GenerationParams {
                prompt: final_prompt.clone(),
                negative: eff_negative.clone(),
                steps: user_set_steps.then_some(eff_steps),
                guidance: user_set_guidance.then_some(eff_guidance),
                scheduler: user_set_scheduler.then(String::new),
            };
            let (look_spec, genre_spec) = apply_presets(
                eff_look.as_deref(),
                eff_genre.as_deref(),
                &mut params,
            )
            .with_context(|| format!("task {:?}: look/genre apply", task.name))?;

            final_prompt = params.prompt;
            eff_negative = params.negative;
            if let Some(s_steps) = params.steps {
                eff_steps = s_steps;
            }
            if let Some(g) = params.guidance {
                eff_guidance = g;
            }
            if let Some(sched) = params.scheduler.filter(|s| !s.is_empty()) {
                eff_scheduler =
                    SchedulerKind::from_str(&sched).unwrap_or(eff_scheduler);
            }

            if let Some(l) = &look_spec {
                crate::ui::progress::println(&format!(
                    "  look '{}': prompt/negative composed (scenario)",
                    l.name
                ));
            }
            if let Some(g) = &genre_spec {
                crate::ui::progress::println(&format!(
                    "  genre '{}': prompt/negative composed (scenario)",
                    g.name
                ));
            }
        }

        // Seed: per-task override picks an absolute seed; global path
        // advances seed_offset to keep later tasks reproducible.
        let task_seed = task.seed.unwrap_or(seed + seed_offset);

        let task_out = out_root.join(safe_name(&task.name));

        // v0.17 phase 5: --resume skip. If every expected output
        // PNG for this task already exists on disk, skip the
        // task entirely (no model dispatch, no enhancer call).
        // Backbones write a few prefixes: `plakat-<seed>.png`
        // (SD t2i), `plakat-flux-<seed>.png` (Flux), `plakat-sd3-
        // <seed>.png` (SD3 t2i), `plakat-img2img-<seed>.png` /
        // `plakat-inpaint-<seed>.png` (img2img). We accept any of
        // those prefixes when probing — gives correct skip
        // behaviour for mixed-task scenarios without per-backbone
        // dispatch.
        //
        // `seed_offset` MUST advance by the global `count`, NOT
        // `eff_count`, to match the non-skip path (line 2739) —
        // the global-seed scheme means re-running with the same
        // scenario file produces the same per-task seeds whether
        // any tasks were skipped or not.
        if args.resume && task_outputs_all_present(&task_out, task_seed, eff_count) {
            crate::ui::progress::println(&format!(
                "  ↺ {}: all {} output(s) already on disk — skipping",
                console::style(&task.name).cyan(),
                eff_count,
            ));
            seed_offset += count as u64;
            continue;
        }

        // Classify the persona configuration for this task.
        //   None        — no personas; regular t2i / flux dispatch.
        //   Single(p)   — Phase-1 form: one bare-name persona, whole image.
        //   Multi(...)  — Phase-2 form: one or more {name, bbox} personas
        //                 routed through region-masked compositing.
        enum TaskPersonas<'a> {
            None,
            Single(&'a PersonaDef),
            Multi(Vec<(&'a PersonaDef, [f32; 4])>),
        }
        let task_persona_mode: TaskPersonas = match task.personas.as_deref() {
            None => TaskPersonas::None,
            Some(refs) if refs.is_empty() => TaskPersonas::None,
            Some(refs) => {
                // Validation guarantees: either all-bare (and len == 1), or
                // all-bbox (len >= 1).
                match refs.first().unwrap() {
                    PersonaRef::Bare(name) => {
                        TaskPersonas::Single(personas_map[name.as_str()])
                    }
                    PersonaRef::Bbox(_) => {
                        let mut v = Vec::with_capacity(refs.len());
                        for r in refs {
                            match r {
                                PersonaRef::Bbox(b) => {
                                    v.push((personas_map[b.name.as_str()], b.bbox));
                                }
                                PersonaRef::Bare(_) => {
                                    unreachable!("form-mixing rejected at load")
                                }
                            }
                        }
                        TaskPersonas::Multi(v)
                    }
                }
            }
        };

        // Filename prefix used by downstream style / upscale passes. Both
        // persona forms output via the portrait pipeline.
        let prefix = match task_persona_mode {
            TaskPersonas::None => {
                if variant.is_flux() { "plakat-flux" } else { "plakat" }
            }
            TaskPersonas::Single(_) | TaskPersonas::Multi(_) => "plakat-portrait",
        };

        // Build a per-persona effective negative (persona-negative prepended
        // to the task's effective negative). Returns the negative string and
        // the persona's effective face_strength.
        let persona_request_for = |persona: &PersonaDef| -> (String, f32) {
            let combined = match persona.negative.as_deref() {
                Some(p_neg) if !p_neg.trim().is_empty() => {
                    if eff_negative.trim().is_empty() {
                        p_neg.to_string()
                    } else {
                        format!("{p_neg}, {eff_negative}")
                    }
                }
                _ => eff_negative.clone(),
            };
            (combined, persona.face_strength.unwrap_or(0.8))
        };

        // -------- v0.9 / v0.13 phase 11 per-task ControlNet plumbing --
        // Resolve the task's effective control list (either the
        // singular `control:` or the multi `controls:` Vec). For each
        // entry, lazy-load the CN weights into the scenario-wide
        // HashMap and prepare its conditioning tensor at the task's
        // working resolution. The result is a parallel Vec of (kind,
        // conditioning_tensor, strength, start, end) tuples consumed
        // by `make_control_reqs` below.
        let task_controls = task_effective_controls(task)?;
        let mut task_cn_resolved: Vec<(
            crate::pipelines::controlnet::ControlKind,
            candle_core::Tensor,
            f32,
            f32,
            f32,
        )> = Vec::with_capacity(task_controls.len());
        for spec in &task_controls {
            let kind: crate::pipelines::controlnet::ControlKind =
                spec.kind.parse().with_context(|| {
                    format!("task {:?}: parsing control.kind {:?}", task.name, spec.kind)
                })?;
            if !controlnets.contains_key(&kind) {
                let net = crate::pipelines::controlnet::ControlNet::load(
                    device.clone(),
                    cn_dtype,
                    kind,
                    cn_variant,
                )
                .await
                .with_context(|| {
                    format!(
                        "task {:?}: loading ControlNet for {:?} ({:?})",
                        task.name, kind, cn_variant,
                    )
                })?;
                controlnets.insert(kind, net);
            }
            let cond = match (spec.image.as_ref(), spec.auto_from.as_ref()) {
                (Some(path), None) => {
                    crate::pipelines::controlnet::prepare_conditioning(
                        path, eff_w, eff_h, &device, cn_dtype,
                    )
                    .with_context(|| {
                        format!(
                            "task {:?}: preparing ControlNet conditioning ({:?})",
                            task.name, kind
                        )
                    })?
                }
                (None, Some(path)) => {
                    crate::pipelines::controlnet_annotator::annotate(
                        kind, path, eff_w, eff_h, &device, cn_dtype,
                    )
                    .await
                    .with_context(|| {
                        format!(
                            "task {:?}: running auto-annotator on {} ({:?})",
                            task.name,
                            path.display(),
                            kind
                        )
                    })?
                }
                (Some(_), Some(_)) => bail!(
                    "task {:?}: control entry must set either `image:` or `auto-from:`, not both",
                    task.name
                ),
                (None, None) => bail!(
                    "task {:?}: control entry requires either `image:` or `auto-from:`",
                    task.name
                ),
            };
            task_cn_resolved.push((
                kind,
                cond,
                spec.strength.unwrap_or(1.0),
                spec.start.unwrap_or(0.0),
                spec.end.unwrap_or(1.0),
            ));
        }
        let make_control_reqs = || -> Vec<crate::pipelines::controlnet::ControlRequest> {
            task_cn_resolved
                .iter()
                .map(|(kind, cond, strength, start, end)| {
                    crate::pipelines::controlnet::ControlRequest {
                        net: controlnets.get(kind).expect("loaded above"),
                        conditioning: cond.clone(),
                        strength: *strength,
                        start: *start,
                        end: *end,
                    }
                })
                .collect()
        };

        match &task_persona_mode {
            // -------- single persona, whole image --------
            TaskPersonas::Single(persona) => {
                let pp = portrait_pipeline
                    .as_ref()
                    .expect("portrait pipeline preloaded when any task uses personas");
                let (combined_negative, face_strength) = persona_request_for(persona);
                let photos = persona.resolve_photos()?;
                let photo_desc = match (photos.first(), photos.len()) {
                    (Some(first), 1) => first.path.display().to_string(),
                    (Some(first), n) => {
                        format!("{} (+{} more, weighted merge)", first.path.display(), n - 1)
                    }
                    _ => String::from("(none)"),
                };
                crate::ui::progress::println(&format!(
                    "  {} persona {} (photo {}, face-strength {:.2})",
                    style("portrait").magenta().bold(),
                    style(&persona.name).bold(),
                    photo_desc,
                    face_strength,
                ));
                pp.generate(&portrait::GenRequest {
                    prompt: final_prompt.clone(),
                    negative: combined_negative,
                    photos,
                    width: eff_w,
                    height: eff_h,
                    count: eff_count,
                    steps: eff_steps,
                    guidance: eff_guidance,
                    seed: Some(task_seed),
                    out_dir: task_out.clone(),
                    scheduler: eff_scheduler,
                    refine: eff_refine,
                    refine_strength: eff_refine_strength,
                    face_strength,
                    face_bbox: persona.face_bbox,
                    face_landmarks: persona.face_landmarks,
                }, &make_control_reqs())?;
            }

            // -------- multi-persona, region-masked compositing --------
            TaskPersonas::Multi(passes) => {
                let pp = portrait_pipeline
                    .as_ref()
                    .expect("portrait pipeline preloaded when any task uses personas");
                std::fs::create_dir_all(&task_out)
                    .with_context(|| format!("creating output dir {}", task_out.display()))?;

                let names_log: Vec<String> = passes
                    .iter()
                    .map(|(p, b)| {
                        format!(
                            "{}@[{:.2},{:.2},{:.2},{:.2}]",
                            p.name, b[0], b[1], b[2], b[3]
                        )
                    })
                    .collect();
                crate::ui::progress::println(&format!(
                    "  {} composite {} persona(s): {}",
                    style("portrait").magenta().bold(),
                    passes.len(),
                    names_log.join(", "),
                ));

                for img_idx in 0..eff_count {
                    let img_seed = (task_seed + img_idx as u64) & (u32::MAX as u64);

                    // Base request: text-only (no photo), same prompt/negative
                    // as the scenario / task.
                    let base_req = portrait::GenRequest {
                        prompt: final_prompt.clone(),
                        negative: eff_negative.clone(),
                        photos: Vec::new(),
                        width: eff_w,
                        height: eff_h,
                        count: 1,
                        steps: eff_steps,
                        guidance: eff_guidance,
                        seed: Some(img_seed),
                        out_dir: task_out.clone(),
                        scheduler: eff_scheduler,
                        refine: None,
                        refine_strength: 0.3,
                        face_strength: 0.0,
                        face_bbox: None,
                        face_landmarks: None,
                    };

                    // Multi-persona: control applies to the base layout pass
                    // only. Each per-persona inpaint pass below skips control
                    // (the persona reference itself drives the local region).
                    let mut latents =
                        pp.generate_latents_one(&base_req, img_seed, &make_control_reqs())?;

                    // Chain one inpaint pass per persona. Each pass uses
                    // a per-persona seed offset so re-running with the same
                    // task_seed yields the same composite.
                    let latent_w = (eff_w as usize) / 8;
                    let latent_h = (eff_h as usize) / 8;
                    for (pass_idx, (persona, bbox)) in passes.iter().enumerate() {
                        let (combined_negative, face_strength) = persona_request_for(persona);
                        let mask = build_persona_mask(
                            *bbox,
                            latent_w,
                            latent_h,
                            &device,
                            pp.latent_dtype(),
                        )?;
                        let pass_photos = persona.resolve_photos()?;
                        let pass_req = portrait::GenRequest {
                            prompt: final_prompt.clone(),
                            negative: combined_negative,
                            photos: pass_photos,
                            width: eff_w,
                            height: eff_h,
                            count: 1,
                            steps: eff_steps,
                            guidance: eff_guidance,
                            seed: Some(img_seed),
                            out_dir: task_out.clone(),
                            scheduler: eff_scheduler,
                            refine: None,
                            refine_strength: 0.3,
                            face_strength,
                            face_bbox: persona.face_bbox,
                    face_landmarks: persona.face_landmarks,
                        };
                        let pass_seed = img_seed
                            .wrapping_add(1)
                            .wrapping_add(pass_idx as u64)
                            & (u32::MAX as u64);
                        latents = pp.inpaint_latents_one(
                            &latents,
                            &mask,
                            &pass_req,
                            pass_seed,
                            &[],
                            // Scenario inpaint uses the portrait pipeline's
                            // SD UNet (RePaint blending) — not SDXL-Inpaint.
                            None,
                        )?;
                    }

                    let out_path = task_out.join(format!("{prefix}-{img_seed}.png"));
                    pp.save_image(&latents, &out_path)?;
                }
            }

            TaskPersonas::None => {
            // -------- regular t2i / flux dispatch (unchanged behaviour) --------
            let gen_req = GenRequest {
                prompt: final_prompt.clone(),
                negative: eff_negative.clone(),
                width: eff_w,
                height: eff_h,
                count: eff_count,
                steps: eff_steps,
                guidance: eff_guidance,
                seed: Some(task_seed),
                out_dir: task_out.clone(),
                scheduler: eff_scheduler,
                refine: eff_refine,
                refine_strength: eff_refine_strength,
                refiner_frac: if s.refiner { Some(eff_refiner_frac) } else { None },
                // v0.16 phase 5: scenarios don't surface per-task
                // clip-skip yet. Default `1` = bit-identical to
                // pre-phase-5 behaviour.
                clip_skip: 1,
                // v0.17 phase 3: scenarios skip per-task PNG
                // metadata for now. Adding it cleanly would require
                // threading task name + scenario file path into the
                // metadata extras; deferred to keep the surface
                // small. None = no tEXt chunk, no sidecar.
                metadata: None,
                // v0.17 phase D: scenarios don't surface live-
                // preview cadence — batch runs typically don't
                // need the per-step PNG churn. None = disabled.
                preview_every: None,
                preview_size: None,
                // v0.19: scenarios don't surface --format yet —
                // default to PNG (the v0.17 A1111-compat path).
                // Per-task webp output lands in a follow-up once
                // the scenario schema is extended.
                output_format: crate::imaging::io::OutputFormat::Png,
            };

            // Per-task runtime LoRA. Applied BEFORE the per-task
            // generate, cleared AFTER (so the next task starts from
            // the scenario-merged baseline).
            //
            // Backbone routing:
            //   * Flux (BF16 / GGUF / NF4) → flux_pipeline backbone
            //   * SD3 / SD3.5              → sd3_pipeline
            //   * SD-family                → bails (SD UNet runtime
            //     LoRA support not yet wired)
            //
            // Empty task.loras = no-op for every backbone.
            let task_lora_applied = if !task.loras.is_empty() {
                apply_task_loras_for_dispatch(
                    task,
                    &task.loras,
                    task.lora_scale.unwrap_or(lora_scale),
                    flux_pipeline.as_mut(),
                    &pipeline,
                    sd3_pipeline.as_mut(),
                    &model,
                    &device,
                )
                .await?
            } else {
                false
            };

            // v0.16 phase 2: SD3 dispatch arm. Routes per-task tasks
            // through the scenario-cached `sd3::Pipeline` rather than
            // building a fresh pipeline per task. Keeps SD3-family
            // LoRA / Tiled wiring intact for runtime per-task LoRA.
            // Falls through to the SD/Flux match for non-SD3 tasks.
            if let Some(sp) = sd3_pipeline.as_mut() {
                use crate::pipelines::sd3;
                // Mirrors the field mapping in t2i.rs's SD3 dispatch.
                // Only forwards steps / guidance when the user moved
                // them off plakat's defaults so SD3's variant-specific
                // recommendations stay in play otherwise.
                let sd3_req = sd3::GenRequest {
                    prompt: final_prompt.clone(),
                    negative: eff_negative.clone(),
                    width: eff_w,
                    height: eff_h,
                    count: eff_count,
                    steps: if eff_steps == 28 { None } else { Some(eff_steps) },
                    guidance: if (eff_guidance - 7.5).abs() < f64::EPSILON {
                        None
                    } else {
                        Some(eff_guidance)
                    },
                    seed: Some(task_seed),
                    out_dir: task_out.clone(),
                    init_image: eff_init_image.clone(),
                    mask: eff_mask.clone(),
                    mask_feather: task.mask_feather.unwrap_or(8),
                    mask_invert: task.mask_invert.unwrap_or(false),
                    strength: task.strength,
                    // v0.15 phase 5: tiled denoise. Composes with
                    // pure t2i; img2img + tiled bails inside the SD3
                    // pipeline (mutually-exclusive design).
                    tiled: eff_tiled.clone(),
                    // v0.16 phase 3: SD3 CN per-call conditioning
                    // overrides. Empty Vec preserves whatever the
                    // load-time conditioning paths were (which is
                    // also empty in scenarios today — SD3 CN scenario
                    // wiring lands in a later phase).
                    controlnet_conditioning: Vec::new(),
                    // v0.20: scenarios don't expose --format yet —
                    // default to PNG (the v0.17 A1111-compat path).
                    output_format: crate::imaging::io::OutputFormat::Png,
                };
                sp.generate(&sd3_req)?;
                if task_lora_applied {
                    sp.clear_all_loras()?;
                    tracing::debug!(
                        target: "plakat",
                        "task {:?}: cleared runtime LoRA stack on SD3 backbone",
                        task.name
                    );
                }
            } else {
            match (&pipeline, flux_pipeline.as_mut()) {
                // SD: reuse the loaded UNet/VAE/CLIP/LoRA across tasks.
                // v0.13 phase 10: dispatch to the tiled SDXL path when
                // the scenario sets `tiled:` (matches `plakat generate
                // --tiled`).
                // v0.13 phase 11: if the task has `init-image:`, hand
                // off to img2img::run (SD img2img / inpaint flow).
                // This currently reloads the SD model per such task —
                // the t2i Pipeline is built for load-once-generate-many,
                // but img2img doesn't share that shape yet. Acceptable
                // for v0.13's batch-rarely-img2img workflows; a follow-
                // up could share the SdCore between paths.
                (Some(p), _) if eff_init_image.is_some() => {
                    if eff_tiled.is_some() {
                        bail!(
                            "task {:?}: --tiled does not yet compose with SD img2img / \
                             inpaint in scenarios. Drop `tiled:` or `init-image:`.",
                            task.name
                        );
                    }
                    // v0.14 phase 7: pass the t2i Pipeline by ref so
                    // `run_sd_img2img_task` can share its SdCore with
                    // the img2img runner. Pre-phase-7 the helper
                    // delegated to `img2img::run` which built its own
                    // ~5GB SD pipeline per task.
                    run_sd_img2img_task(
                        task,
                        &gen_req,
                        &task_controls,
                        &model,
                        &loras,
                        lora_scale,
                        eff_scheduler,
                        &device,
                        eff_init_image.as_ref().unwrap(),
                        eff_mask.as_deref(),
                        p,
                    )
                    .await?;
                }
                (Some(p), _) => match eff_tiled.as_ref() {
                    Some(tcfg) => p.generate_tiled(&gen_req, tcfg.clone())?,
                    None => p.generate(&gen_req, &make_control_reqs())?,
                },
                // Flux: reuse the loaded transformer + AE + T5 + CLIP across tasks.
                (_, Some(fp)) => {
                    // Pass `steps` / `guidance` through to Flux only if they
                    // diverge from plakat's generic defaults (28 / 7.5) so
                    // Flux's variant-specific defaults stay in play otherwise.
                    let flux_steps = if eff_steps == 28 { None } else { Some(eff_steps) };
                    let flux_guidance = if (eff_guidance - 7.5).abs() < f64::EPSILON {
                        None
                    } else {
                        Some(eff_guidance)
                    };

                    // v0.13 phase 10/11: per-task Flux ControlNet swap.
                    // The Pipeline holds up to `max_flux_controls`
                    // Union Pro v2 instances pre-loaded at scenario
                    // startup. For each entry in the task's
                    // `effective_controls()` we mutate one slot's
                    // call params + conditioning. Unused slots are
                    // cleared so they contribute no residuals.
                    // `_cn_tmps` outlives the `generate` call so any
                    // auto-annotator PNGs stay readable.
                    //
                    // v0.14 phase 3c: parse the task's Redux specs
                    // here so an invalid spec fails the task with a
                    // clear error before the slow generate kicks in.
                    let task_redux_specs: Vec<
                        crate::pipelines::flux_redux::ReduxSpec,
                    > = task
                        .redux_images
                        .iter()
                        .map(|s| s.parse::<crate::pipelines::flux_redux::ReduxSpec>())
                        .collect::<Result<Vec<_>>>()
                        .with_context(|| {
                            format!("task {:?}: parsing redux-images entries", task.name)
                        })?;
                    let task_flux_controls = task_effective_controls(task)?;
                    let mut _cn_tmps: Vec<tempfile::TempDir> = Vec::new();
                    if fp.has_controlnets() {
                        let slots = fp.controlnet_count();
                        if task_flux_controls.len() > slots {
                            bail!(
                                "task {:?}: {} controls requested but only {} Flux CN slots \
                                 pre-loaded — scenario startup undercount",
                                task.name,
                                task_flux_controls.len(),
                                slots
                            );
                        }
                        for (slot, cspec) in task_flux_controls.iter().enumerate() {
                            let parsed: crate::pipelines::controlnet::ControlKind = cspec
                                .kind
                                .parse()
                                .with_context(|| {
                                    format!(
                                        "task {:?} control[{slot}] kind '{}' not recognised",
                                        task.name, cspec.kind
                                    )
                                })?;
                            // Shakker-Labs Union Pro v2 mode index per kind
                            // — same mapping the CLI dispatch uses.
                            use crate::pipelines::controlnet::ControlKind as CK;
                            let mode = Some(match parsed {
                                CK::Canny | CK::Lineart => 0u32,
                                CK::SoftEdge => 1u32,
                                CK::OpenPose => 2u32,
                                CK::Depth => 3u32,
                            });
                            let scale = cspec.strength.unwrap_or(1.0);
                            let start = cspec.start.unwrap_or(0.0);
                            let end = cspec.end.unwrap_or(1.0);
                            fp.set_controlnet_call_params(slot, mode, scale, start, end)?;
                            let cond_path = match (cspec.image.as_ref(), cspec.auto_from.as_ref()) {
                                (Some(p), None) => p.clone(),
                                (None, Some(from_path)) => {
                                    let anno_dtype = if matches!(device, Device::Cpu) {
                                        candle_core::DType::F32
                                    } else {
                                        candle_core::DType::BF16
                                    };
                                    let anno = crate::pipelines::controlnet_annotator::annotate(
                                        parsed, from_path, eff_w, eff_h, &device, anno_dtype,
                                    )
                                    .await?;
                                    let tmp = tempfile::Builder::new()
                                        .prefix("plakat-scenario-flux-anno-")
                                        .tempdir()?;
                                    let out_path = tmp
                                        .path()
                                        .join(format!("cn{slot}-{}.png", parsed.slug()));
                                    write_flux_anno_png(&anno, &out_path)?;
                                    _cn_tmps.push(tmp);
                                    out_path
                                }
                                (Some(_), Some(_)) => bail!(
                                    "task {:?} control[{slot}]: image and auto-from are mutually exclusive",
                                    task.name
                                ),
                                (None, None) => bail!(
                                    "task {:?} control[{slot}]: requires image or auto-from",
                                    task.name
                                ),
                            };
                            fp.set_controlnet_conditioning(slot, Some(cond_path))?;
                        }
                        // Clear any leftover slots from a previous task
                        // that had more controls than this one.
                        for slot in task_flux_controls.len()..slots {
                            fp.set_controlnet_conditioning(slot, None)?;
                        }
                    }

                    fp.generate(&flux::GenRequest {
                        prompt: gen_req.prompt.clone(),
                        width: gen_req.width,
                        height: gen_req.height,
                        count: gen_req.count,
                        steps: flux_steps,
                        guidance: flux_guidance,
                        seed: gen_req.seed,
                        out_dir: gen_req.out_dir.clone(),
                        // Per-CN conditioning is now set via
                        // `set_controlnet_conditioning` (per-slot)
                        // rather than the singular `req.conditioning`
                        // back-compat shim.
                        conditioning: None,
                        // v0.13 phase 10/11: surface Flux img2img /
                        // Fill / outpaint inputs and tiled denoise at
                        // task scope. `eff_init_image` / `eff_mask`
                        // are the outpaint-synthesised canvas+mask
                        // when `outpaint:` is set, else passthrough.
                        init_image: eff_init_image.clone(),
                        mask: eff_mask.clone(),
                        strength: task.strength,
                        // v0.15 phase 7a: per-task tiled override
                        // (was scenario-global only).
                        tiled: eff_tiled.clone(),
                        // v0.14 phase 3c: per-task Redux. The
                        // task's `redux-images: [...]` is parsed
                        // upstream as `Vec<ReduxSpec>`; pass it
                        // through verbatim. The Pipeline was loaded
                        // with `redux: any_task_has_redux` above so
                        // the encoder is available iff any task uses
                        // it.
                        redux_images: task_redux_specs.clone(),
                        // v0.15 phase 7a: concept-variant conditioning
                        // — `task.concept-image:` overrides scenario
                        // `concept-image:`; bails inside the Flux
                        // pipeline if the loaded variant doesn't
                        // expect this input.
                        concept_conditioning: eff_concept_image.clone(),
                        // v0.18 Kontext phase 4: opt-in 17-bucket
                        // aspect snap. Ignored when the resolved
                        // variant isn't Kontext.
                        kontext_bucket: eff_kontext_bucket,
                        // v0.20: scenarios don't expose --format yet.
                        output_format: crate::imaging::io::OutputFormat::Png,
                    })?;
                }
                // Dry-run path doesn't reach here.
                (None, None) => unreachable!("non-dry-run task without a pipeline"),
            }

            // v0.15 phase 7b-7: clear the runtime LoRA stack after
            // the task generates. Restores every LoraLinear to the
            // scenario-merged baseline so the next task isn't
            // contaminated by this task's deltas. No-op when
            // task.loras was empty.
            if task_lora_applied {
                if let Some(fp) = flux_pipeline.as_mut() {
                    fp.backbone().clear_all_loras()?;
                    tracing::debug!(
                        target: "plakat",
                        "task {:?}: cleared runtime LoRA stack",
                        task.name
                    );
                }
            }
            } // close `else` (v0.16 phase 2 SD3 dispatch branch)
            }
        }

        // -------- artefact compositing (post-generate, pre-stylize) --------
        // Stylize will re-paint over the composited artefacts via IP-Adapter,
        // unifying their palette with the generated scene — that's the
        // reason this step lands before stylize, not after.
        if !task.artefacts.is_empty() {
            let specs: Vec<crate::artefacts::ArtefactSpec> = task
                .artefacts
                .iter()
                .map(crate::artefacts::ArtefactSpecEntry::to_spec)
                .collect::<Result<_>>()
                .with_context(|| format!("task {:?}: parsing artefact specs", task.name))?;
            let library_dir = s
                .artefact_library
                .clone()
                .unwrap_or_else(|| PathBuf::from("assets/artefact_library"));

            // v3: lazily load DepthPipeline on first task that wants it.
            // Subsequent tasks reuse the loaded instance; load failure
            // is sticky for the rest of the run (we don't retry).
            let task_smart = task.smart_zones.unwrap_or(s.smart_zones);
            if task_smart && smart_depth.is_none() && !smart_depth_attempted && any_smart {
                smart_depth_attempted = true;
                match crate::pipelines::depth::DepthPipeline::load(device.clone()).await {
                    Ok(p) => smart_depth = Some(p),
                    Err(e) => {
                        crate::ui::progress::println(&format!(
                            "  {} smart-zones depth load failed ({e}). Falling back \
                             to rigid grid for this run.",
                            style("warn:").yellow().bold(),
                        ));
                    }
                }
            }
            let smart_ref = if task_smart { smart_depth.as_ref() } else { None };

            crate::artefacts::composite_onto_seed_range(
                &specs,
                &library_dir,
                &task_out,
                Some(task_seed),
                eff_count,
                prefix,
                eff_w,
                eff_h,
                &s.zones,
                smart_ref,
            )
            .with_context(|| format!("task {:?}: compositing artefacts", task.name))?;

            // v2: masked img2img blending pass — per-task override
            // falls back to scenario-level toggle.
            let blend_on = task.artefact_blend.unwrap_or(s.artefact_blend);
            if blend_on {
                let strength = task
                    .artefact_blend_strength
                    .or(s.artefact_blend_strength)
                    .unwrap_or(0.3);
                let files: Vec<PathBuf> = (0..eff_count)
                    .map(|i| {
                        let seed = task_seed.wrapping_add(i as u64);
                        task_out.join(format!("{prefix}-{seed}.png"))
                    })
                    .filter(|p| p.exists())
                    .collect();
                crate::pipelines::artefact_blend::blend_files(
                    crate::pipelines::artefact_blend::BlendConfig {
                        model: model.clone(),
                        device: device.clone(),
                        loras: loras.clone(),
                        lora_scale,
                        prompt: final_prompt.clone(),
                        negative: eff_negative.clone(),
                        image_w: eff_w,
                        image_h: eff_h,
                        steps: eff_steps,
                        guidance: eff_guidance,
                        scheduler: eff_scheduler,
                        strength,
                        feather_px: None,
                    },
                    &specs,
                    &library_dir,
                    &files,
                    &s.zones,
                    Some(task_seed),
                    smart_ref,
                    // Phase 7e: reuse the scenario's t2i pipeline core
                    // when present. The blend's BlendConfig.model is
                    // already the scenario's main `model` (same one the
                    // t2i pipeline was loaded with), so the core matches.
                    // For dry-run or Flux scenarios the t2i pipeline is
                    // None — fall back to the blend's own load. (Flux
                    // blends aren't supported anyway; this just keeps
                    // the dry-run path inert.)
                    pipeline.as_ref().map(|p| p.core()),
                )
                .await
                .with_context(|| format!("task {:?}: blending artefacts", task.name))?;
            }
        }

        // Optional post-generate style pass.
        let style_attempted = task.style.is_some();
        if let Some(style_ref) = &task.style {
            if !style_ref.exists() {
                crate::ui::progress::println(&format!(
                    "  {} style reference not found: {} — skipping",
                    style("warn:").yellow().bold(),
                    style_ref.display(),
                ));
            } else if let Some(sp) = stylize_pipeline.as_ref() {
                run_style_pass(
                    sp,
                    style_ref,
                    task.style_strength.unwrap_or(0.6),
                    &task_out,
                    task_seed,
                    eff_count,
                    prefix,
                );
            }
        }

        // Optional post-generate upscale pass.
        // Targets the stylized image when stylize was requested, otherwise the
        // original. Falls back to the original (with a warning) if the styled
        // file isn't on disk (e.g. stylize failed).
        if s.upscale.upscale {
            run_upscale_pass(
                &task_out,
                task_seed,
                eff_count,
                prefix,
                style_attempted,
                s.upscale.scale,
                upscale_method,
                &device,
                esrgan.as_ref(),
            )
            .await;
        }

        // Global seed_offset always advances by the GLOBAL count so a
        // re-run with the same scenario gives the same global-seed
        // tasks the same composition, regardless of per-task overrides.
        seed_offset += count as u64;
    }

    // v0.18: tag the summary line so dry-run users can tell at
    // a glance that nothing was actually written to disk. Without
    // this, "✓ done N images" misleads — they'd look in the out
    // dir, find it empty, and wonder what happened.
    if args.dry_run {
        println!(
            "\n{} would have generated {} image(s) across {} task(s) → {} \
             (no files written — drop --dry-run to actually generate)",
            style("(dry-run)").yellow().bold(),
            total_images,
            s.tasks.len(),
            out_root.display()
        );
    } else {
        println!(
            "\n{} {} task(s), {} image(s) → {}",
            style("✓ done").green().bold(),
            s.tasks.len(),
            total_images,
            out_root.display()
        );
    }
    Ok(())
}

/// v0.16 phase 11: surface SD-family per-task LoRA incompatibility
/// upfront with actionable guidance. Catches three patterns:
///
/// 1. **All task LoRA stacks identical** → suggest folding to
///    scenario-level `loras:` (zero-cost fix, applied once at load).
/// 2. **Stacks vary across tasks** → suggest Flux / SD3.5 switch or
///    splitting the scenario.
/// 3. **SD3 / Flux model** → no-op (those support runtime LoRA).
///
/// Runs ONCE at scenario start, before any model load. Cheap.
fn sd_per_task_lora_preflight(
    s: &ScenarioFile,
    model: &str,
) -> Result<()> {
    use crate::pipelines::t2i::Variant;
    let variant = Variant::detect(model);
    // SD3 + Flux support runtime per-task LoRA; nothing to warn.
    if variant.is_flux() || variant.is_sd3() {
        return Ok(());
    }
    // Collect tasks that declare per-task loras.
    let with_loras: Vec<&TaskDef> = s
        .tasks
        .iter()
        .filter(|t| !t.loras.is_empty())
        .collect();
    if with_loras.is_empty() {
        return Ok(());
    }
    // Pattern 1: every task with loras has the same stack.
    let first = &with_loras[0].loras;
    let all_same = with_loras.iter().all(|t| t.loras == *first);
    if all_same && with_loras.len() == s.tasks.len() {
        // Every task uses the same loras AND no task omits them.
        // Folding to scenario-level is byte-equivalent.
        crate::ui::progress::println(&format!(
            "  {} every task uses the same per-task `loras:` stack ({} entries). \
             On SD-family models per-task LoRA runtime swap isn't wired \
             (vendor work — see phase 11 notes). The stack is identical across \
             tasks, so it can be folded to scenario-level `loras:` for the same \
             result without the per-task swap requirement:",
            console::style("hint:").yellow().bold(),
            first.len(),
        ));
        crate::ui::progress::println(&format!("    loras: {first:?}"));
        crate::ui::progress::println(&format!(
            "    {} drop the per-task `loras:` from every task block",
            console::style("then").dim(),
        ));
        return Ok(());
    }
    // Pattern 2: stacks vary. Bail clearly upfront so the user
    // doesn't watch model load for 30s before hitting the per-task
    // apply_loras bail.
    anyhow::bail!(
        "scenario uses SD-family model `{model}` with per-task LoRA stacks that \
         vary across {} task(s) — SD UNet runtime per-task LoRA swap isn't \
         wired yet (vendor work; see phase 11). Workarounds:\n  \
         1. Switch to a Flux / SD3.5 model (instant per-task LoRA swap via the \
         runtime stack).\n  \
         2. Split into multiple scenarios, one per LoRA stack — each pays the \
         SD UNet load once.\n  \
         3. If you only need ONE per-task LoRA combination, move it to the \
         scenario-level `loras:` block and drop the per-task overrides.",
        with_loras.len()
    );
}

fn validate_enhancer_keys(enhancer: &str) -> Result<()> {
    let cfg = crate::config::Config::load()?;
    let lower = enhancer.to_lowercase();
    // v0.20 #5: accept the `local` + `local:<alias>` + `auto`
    // providers `prompt::enhance` already supports. Previously
    // scenarios were gated to cloud providers only, which made
    // `plakat init`-generated starters non-runnable without an
    // API key.
    if lower == "local" || lower.starts_with("local:") || lower == "auto" {
        return Ok(());
    }
    match lower.as_str() {
        "deepseek" => {
            if cfg.deepseek_api_key.is_none() {
                bail!(
                    "scenario uses `enhancer: deepseek` but DEEPSEEK_API_KEY \
                     is not set in the environment or ~/.config/plakat/config.toml"
                );
            }
        }
        "gemini" => {
            if cfg.gemini_api_key.is_none() {
                bail!(
                    "scenario uses `enhancer: gemini` but GEMINI_API_KEY \
                     is not set in the environment or ~/.config/plakat/config.toml"
                );
            }
        }
        other => bail!(
            "unknown enhancer {other:?} \
             (expected: deepseek | gemini | local | local:<alias> | auto)"
        ),
    }
    Ok(())
}

/// Compare two LoRA spec lists for equivalence under the per-task
/// style override constraint: scenarios share a pre-loaded pipeline,
/// so we can't swap LoRAs per task. If a per-task style's resolved
/// LoRAs match the currently-loaded set (typically both empty for
/// trigger-only styles), the override is safe; otherwise we warn.
///
/// Compared by spec-string + scale, which is what the user-facing
/// catalog encodes — bit-for-bit `LoraSpec` equality isn't useful
/// because the type doesn't implement `PartialEq`.
fn same_lora_set(a: &[LoraSpec], b: &[LoraSpec]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let key = |s: &LoraSpec| format!("{:?}|{}", s.source, s.scale);
    let mut a_keys: Vec<String> = a.iter().map(key).collect();
    let mut b_keys: Vec<String> = b.iter().map(key).collect();
    a_keys.sort();
    b_keys.sort();
    a_keys == b_keys
}

/// Truncate a string to `max` characters for inclusion in error messages.
/// Appends `…` when truncated. Char-boundary safe.
fn trim_preview(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        s.to_owned()
    } else {
        let truncated: String = s.chars().take(max).collect();
        format!("{}…", truncated)
    }
}

/// Strip leading/trailing whitespace and commas from each part so users can
/// write fragments like `"fantasy art,"` or `, masterpiece` without producing
/// double-commas in the final prompt.
fn join_parts(parts: &[&str]) -> String {
    parts
        .iter()
        .map(|s| s.trim().trim_matches(|c: char| c == ',' || c.is_whitespace()))
        .filter(|s| !s.is_empty())
        .collect::<Vec<_>>()
        .join(", ")
}

/// Run the IP-Adapter stylize pass on every image produced by a task. The
/// original `plakat-<seed>.png` (or `plakat-flux-<seed>.png`) is preserved;
/// the styled version is written next to it as `…-styled.png`.
///
/// Failures on individual images are logged but don't abort the scenario —
/// you keep the original even if stylization fails (e.g. SDXL output with
/// dims stylize can't handle).
fn run_style_pass(
    pipeline: &stylize::Pipeline,
    ref_path: &std::path::Path,
    strength: f32,
    out_dir: &std::path::Path,
    seed_start: u64,
    count: u32,
    prefix: &str,
) {
    for i in 0..count {
        let seed = (seed_start + i as u64) & (u32::MAX as u64);
        let in_path = out_dir.join(format!("{prefix}-{seed}.png"));
        let out_path = out_dir.join(format!("{prefix}-{seed}-styled.png"));

        if !in_path.exists() {
            crate::ui::progress::println(&format!(
                "  {} expected {} not on disk — stylize skipped",
                style("warn:").yellow().bold(),
                in_path.display(),
            ));
            continue;
        }

        crate::ui::progress::println(&format!(
            "  {} {} (REF {}, strength {:.2})",
            style("stylize").cyan().bold(),
            in_path.display(),
            ref_path.display(),
            strength,
        ));

        let req = stylize::GenRequest {
            input: in_path,
            reference: ref_path.to_path_buf(),
            out: out_path,
            strength,
            steps: 30,
            seed: Some(seed),
        };
        if let Err(e) = pipeline.stylize_one(&req) {
            crate::ui::progress::println(&format!(
                "  {} stylize failed: {e}",
                style("warn:").yellow().bold(),
            ));
        }
    }
}

/// Run the classical upscaler on every image produced by a task.
///
/// Target file:
///   - If `style_attempted` and `<task>/plakat-<seed>-styled.png` exists → upscale it.
///   - Else → upscale `<task>/plakat-<seed>.png`.
///
/// Output is written next to the source with `-upscaled` appended.
async fn run_upscale_pass(
    out_dir: &std::path::Path,
    seed_start: u64,
    count: u32,
    prefix: &str,
    style_attempted: bool,
    scale: f32,
    method: UpscaleMethod,
    device: &candle_core::Device,
    esrgan: Option<&EsrganPipeline>,
) {
    for i in 0..count {
        let seed = (seed_start + i as u64) & (u32::MAX as u64);
        let styled = out_dir.join(format!("{prefix}-{seed}-styled.png"));
        let orig = out_dir.join(format!("{prefix}-{seed}.png"));

        // Pick source per the rule.
        let (source, suffix) = if style_attempted && styled.exists() {
            (styled, "styled-upscaled")
        } else {
            if style_attempted {
                crate::ui::progress::println(&format!(
                    "  {} styled image missing; upscaling original instead",
                    style("warn:").yellow().bold(),
                ));
            }
            (orig, "upscaled")
        };
        if !source.exists() {
            crate::ui::progress::println(&format!(
                "  {} {} not on disk — upscale skipped",
                style("warn:").yellow().bold(),
                source.display(),
            ));
            continue;
        }
        let dest = out_dir.join(format!("{prefix}-{seed}-{suffix}.png"));

        let result = match (method.is_ml(), esrgan) {
            // Cached ESRGAN model — no per-image build cost.
            (true, Some(p)) => p.upscale_file(&source, &dest),
            // ML method but no preloaded pipeline (shouldn't happen in normal
            // scenario flow; fall back to the one-shot path).
            (true, None) => {
                crate::imaging::upscale::ml_upscale(&source, &dest, method, device).await
            }
            (false, _) => crate::imaging::upscale::upscale(&source, &dest, scale, method),
        };
        match result {
            Ok((w, h, nw, nh)) => {
                let shown = method.native_scale().unwrap_or(scale);
                crate::ui::progress::println(&format!(
                    "  {} {} ({}×{} → {}×{}, {:.2}×, {:?})",
                    style("upscale").cyan().bold(),
                    dest.display(),
                    w,
                    h,
                    nw,
                    nh,
                    shown,
                    method,
                ));
            }
            Err(e) => crate::ui::progress::println(&format!(
                "  {} upscale failed for {}: {e}",
                style("warn:").yellow().bold(),
                source.display(),
            )),
        }
    }
}

/// Word-wrap `text` under a labeled line. Continuation lines are indented
/// to line up after the `"  <label>: "` prefix so the result reads as one
/// logical entry. Existing newlines in `text` are treated as whitespace
/// (HJSON multi-line strings carry editor-formatting newlines that aren't
/// semantically meaningful to SD).
///
/// Format:
///     "  pre-enhance: first line of wrapped text up to terminal width"
///     "               second line continues at the same column"
///     "               third line ..."
fn wrap_label(label: &str, text: &str) -> String {
    let cols = terminal_width();
    let prefix_len = 2 + label.chars().count() + 2; // "  " + label + ": "
    let avail = cols.saturating_sub(prefix_len).max(40);
    let indent = " ".repeat(prefix_len);

    let mut lines: Vec<String> = Vec::new();
    let mut current = String::new();
    for word in text.split_whitespace() {
        if current.is_empty() {
            current.push_str(word);
        } else if current.chars().count() + 1 + word.chars().count() <= avail {
            current.push(' ');
            current.push_str(word);
        } else {
            lines.push(std::mem::take(&mut current));
            current.push_str(word);
        }
    }
    if !current.is_empty() {
        lines.push(current);
    }
    if lines.is_empty() {
        lines.push(String::new());
    }

    let label_styled = style(label).dim();
    let mut out = format!("  {label_styled}: {}", lines[0]);
    for line in &lines[1..] {
        out.push('\n');
        out.push_str(&indent);
        out.push_str(line);
    }
    out
}

/// Any non-default per-task field set? Used to decide whether the dry-run
/// should print an "overrides:" line.
fn has_overrides(task: &TaskDef) -> bool {
    task.size.is_some()
        || task.aspect.is_some()
        || task.count.is_some()
        || task.steps.is_some()
        || task.guidance.is_some()
        || task.seed.is_some()
        || task.negative.is_some()
        || task.scheduler.is_some()
        || task.refine.is_some()
        || task.refine_strength.is_some()
        || task.refiner_frac.is_some()
}

fn describe_overrides(task: &TaskDef) -> String {
    let mut parts: Vec<String> = Vec::new();
    if let Some(v) = &task.size {
        parts.push(format!("size={v}"));
    }
    if let Some(v) = &task.aspect {
        parts.push(format!("aspect={v}"));
    }
    if let Some(v) = task.count {
        parts.push(format!("count={v}"));
    }
    if let Some(v) = task.steps {
        parts.push(format!("steps={v}"));
    }
    if let Some(v) = task.guidance {
        parts.push(format!("guidance={v}"));
    }
    if let Some(v) = task.seed {
        parts.push(format!("seed={v}"));
    }
    if task.negative.is_some() {
        parts.push("negative=…".to_string());
    }
    if let Some(v) = &task.scheduler {
        parts.push(format!("scheduler={v}"));
    }
    if let Some(v) = task.refine {
        parts.push(format!("refine={v}"));
    }
    if let Some(v) = task.refine_strength {
        parts.push(format!("refine-strength={v}"));
    }
    if let Some(v) = task.refiner_frac {
        parts.push(format!("refiner-frac={v}"));
    }
    parts.join(", ")
}

fn terminal_width() -> usize {
    console::Term::stdout()
        .size_checked()
        .map(|(_, c)| c as usize)
        .unwrap_or(100)
}

/// Build a `(1, 1, latent_h, latent_w)` mask from a normalised bbox.
///
/// `bbox = [x0, y0, x1, y1]` is in the unit square. Pixel-space bounds
/// are computed against the latent dimensions (not the image dims) so
/// the mask aligns with what the UNet sees. Values are `1.0` inside
/// the bbox, `0.0` outside; the dtype matches the pipeline's latents
/// so `broadcast_mul` works without an extra cast.
///
/// Edges round inward: x0/y0 use `ceil`, x1/y1 use `floor`, so the
/// mask always strictly fits inside the bbox.
fn build_persona_mask(
    bbox: [f32; 4],
    latent_w: usize,
    latent_h: usize,
    device: &candle_core::Device,
    dtype: candle_core::DType,
) -> Result<candle_core::Tensor> {
    let [x0, y0, x1, y1] = bbox;
    let lw = latent_w as f32;
    let lh = latent_h as f32;
    let xs = (x0 * lw).floor().max(0.0) as usize;
    let ys = (y0 * lh).floor().max(0.0) as usize;
    let xe = (x1 * lw).ceil().min(lw) as usize;
    let ye = (y1 * lh).ceil().min(lh) as usize;

    // Defensive: if the bbox collapses (e.g. a 1-pixel persona slot at
    // 32× latent compression), expand to at least 1×1 so the mask isn't
    // entirely zero (which would make inpaint a no-op).
    let xe = xe.max(xs + 1).min(latent_w);
    let ye = ye.max(ys + 1).min(latent_h);

    let mut buf = vec![0f32; latent_w * latent_h];
    for y in ys..ye {
        for x in xs..xe {
            buf[y * latent_w + x] = 1.0;
        }
    }
    let t = candle_core::Tensor::from_vec(buf, (1, 1, latent_h, latent_w), device)?;
    Ok(t.to_dtype(dtype)?)
}

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

/// v0.17 phase 5: check whether every expected output PNG for
/// a task is already on disk. Probes each of the four prefixes
/// backbones write under (`plakat-`, `plakat-flux-`, `plakat-sd3-`,
/// `plakat-img2img-`, `plakat-inpaint-`, `plakat-portrait-`) — if
/// **any** prefix has all N seeds present, the task is treated as
/// already-generated and `--resume` skips it.
///
/// Per-image seed: `task_seed + i`, masked to `u32::MAX` to match
/// what the SD save sites use.
fn task_outputs_all_present(task_out: &std::path::Path, task_seed: u64, count: u32) -> bool {
    if count == 0 {
        return false;
    }
    if !task_out.exists() {
        return false;
    }
    let prefixes = [
        "plakat-",
        "plakat-flux-",
        "plakat-sd3-",
        "plakat-img2img-",
        "plakat-inpaint-",
        "plakat-portrait-",
    ];
    for prefix in prefixes {
        let all_present = (0..count).all(|i| {
            let seed = (task_seed + i as u64) & (u32::MAX as u64);
            let path = task_out.join(format!("{prefix}{seed}.png"));
            path.exists()
        });
        if all_present {
            return true;
        }
    }
    false
}

/// v0.13 phase 10: write a `(1, 3, H, W)` `[0, 1]` ControlNet annotator
/// tensor as an 8-bit RGB PNG. Mirrors `t2i::write_annotator_tensor_as_png`
/// (kept private there) so scenarios don't take a dependency on
/// internal pipeline code.
/// v0.13 phase 11: dispatch one SD img2img / inpaint task in a
/// scenario by delegating to `img2img::run`. Reloads the SD model
/// per call (img2img doesn't share the t2i Pipeline's load-once-
/// generate-many shape today). Caller has already validated that
/// `task.init_image.is_some()`.
#[allow(clippy::too_many_arguments)]
/// v0.15 phase 7b-7: apply a task's per-task runtime LoRA stack.
///
/// Backbone routing:
/// * Flux (BF16 / GGUF / NF4): resolves user specs via
///   `flux_lora::compute_runtime_specs` and calls
///   `FluxBackbone::apply_loras`.
/// * SD3 / SD3.5: resolves via `sd3_lora::compute_runtime_specs` and
///   calls `sd3::Pipeline::apply_loras`. The scenario caches the
///   `sd3::Pipeline` across tasks so the runtime LoRA stack is the
///   per-task delta on top of the scenario-merged baseline.
/// * SD-family (sd15 / sd21 / sdxl): bails — the SD UNet's Linears
///   aren't yet wrapped as `LoraLinear`. Use scenario-level `loras:`
///   for SD models.
///
/// Returns `true` when a stack was applied (caller clears at
/// end-of-task), `false` when no application happened.
///
/// Returns `Ok(true)` when a stack was successfully applied (caller
/// must clear after the task) and `Ok(false)` when no application
/// happened (e.g. all specs resolved to unknown targets — silent
/// skip with a debug log).
async fn apply_task_loras_for_dispatch(
    task: &TaskDef,
    task_loras: &[String],
    task_lora_scale: f32,
    flux_pipeline: Option<&mut crate::pipelines::flux::Pipeline>,
    sd_pipeline: &Option<crate::pipelines::t2i::Pipeline>,
    sd3_pipeline: Option<&mut crate::pipelines::sd3::Pipeline>,
    model: &str,
    device: &Device,
) -> Result<bool> {
    use crate::pipelines::lora::LoraSpec;
    use crate::pipelines::t2i::Variant as TVariant;

    // Parse + resolve user specs (downloads any HF-hosted LoRAs).
    let parsed: Vec<LoraSpec> = task_loras
        .iter()
        .map(|s| s.parse::<LoraSpec>())
        .collect::<Result<_>>()
        .with_context(|| format!("task {:?}: parsing loras", task.name))?;
    if parsed.is_empty() {
        return Ok(false);
    }
    let mut resolved: Vec<crate::pipelines::lora::ResolvedLora> =
        Vec::with_capacity(parsed.len());
    for spec in &parsed {
        resolved.push(spec.resolve().await.with_context(|| {
            format!("task {:?}: resolving lora {spec:?}", task.name)
        })?);
    }

    let variant = TVariant::detect(model);
    if variant.is_flux() {
        let fp = flux_pipeline.ok_or_else(|| {
            anyhow::anyhow!(
                "task {:?}: declared Flux task LoRAs but no Flux pipeline loaded",
                task.name
            )
        })?;
        let (specs, modified, total) =
            crate::pipelines::flux_lora::compute_runtime_specs(
                &resolved, task_lora_scale, device,
            )?;
        tracing::info!(
            target: "plakat",
            "task {:?}: staging {} per-task Flux runtime LoRA target(s) ({modified}/{total} groups)",
            task.name,
            specs.len()
        );
        let dtype = fp.dtype();
        let applied = fp.backbone().apply_loras(specs, dtype, device)?;
        tracing::debug!(
            target: "plakat",
            "task {:?}: applied {} per-task runtime LoRA target(s) to Flux backbone",
            task.name, applied
        );
        return Ok(true);
    }

    if variant.is_sd3() {
        let sp = sd3_pipeline.ok_or_else(|| {
            anyhow::anyhow!(
                "task {:?}: declared SD3 task LoRAs but no SD3 pipeline loaded",
                task.name
            )
        })?;
        // v0.16 phase 2: per-task SD3 LoRA via the runtime stack.
        // Mirrors the Flux dispatch — `compute_runtime_specs` builds
        // the path-keyed map, then `apply_loras` updates every
        // registered LoraLinear in MMDiT.
        let hidden_size = sp.variant().mmdit_hidden_size();
        let (specs, modified, total) =
            crate::pipelines::sd3_lora::compute_runtime_specs(
                &resolved, task_lora_scale, hidden_size, device,
            )?;
        tracing::info!(
            target: "plakat",
            "task {:?}: staging {} per-task SD3 runtime LoRA target(s) ({modified}/{total} groups)",
            task.name,
            specs.len()
        );
        let applied = sp.apply_loras(specs)?;
        tracing::debug!(
            target: "plakat",
            "task {:?}: applied {} per-task runtime LoRA target(s) to SD3 backbone",
            task.name, applied
        );
        return Ok(true);
    }

    if sd_pipeline.is_some() {
        anyhow::bail!(
            "task {:?}: per-task LoRA on SD-family (SD 1.5 / 2.1 / SDXL) needs a \
             runtime-swappable UNet. The proper fix vendors candle's UNet model so \
             every internal `nn::Linear` can be wrapped as `LoraLinear` (same pattern \
             Flux + SD3 use — see `pipelines::flux_lora` / `pipelines::sd3_lora`). \
             Without that, each per-task LoRA swap would require reloading the UNet \
             from disk (~15-30s overhead per task on SDXL), defeating the runtime \
             stack's purpose. Workarounds:\n  \
             1. Move per-task LoRAs to the scenario's `loras:` block when they're \
             the same across every task — applied once at load time, no per-task \
             cost.\n  \
             2. Switch to Flux / SD3.5 models — they support instant per-task LoRA \
             swap via the runtime LoraLinear stack (v0.15 phase 7b + v0.16 phase 2).\n  \
             3. Split into multiple scenarios, one per LoRA stack — the SD UNet \
             load is a one-time cost per scenario.",
            task.name
        );
    }

    // Unreachable in practice — dry-run paths short-circuit earlier.
    Ok(false)
}

async fn run_sd_img2img_task(
    task: &TaskDef,
    gen_req: &GenRequest,
    task_controls: &[&ControlSpec],
    model: &str,
    loras: &[crate::pipelines::lora::LoraSpec],
    lora_scale: f32,
    scheduler: SchedulerKind,
    device: &Device,
    init_image: &std::path::Path,
    mask: Option<&std::path::Path>,
    sd_pipeline: &Pipeline,
) -> Result<()> {
    use crate::pipelines::{controlnet, img2img, portrait};

    // Default strength matches the CLI flow: 0.6 for img2img, 1.0 for
    // inpaint. Task can override via `strength:`.
    let strength = task
        .strength
        .unwrap_or_else(|| if mask.is_some() { 1.0 } else { 0.6 });
    if !(0.0..=1.0).contains(&strength) || !strength.is_finite() {
        bail!(
            "task {:?}: strength must be finite in [0, 1], got {strength}",
            task.name
        );
    }
    // Translate scenario ControlSpec → CLI/library ControlSpec.
    let mut cli_controls: Vec<controlnet::ControlSpec> = Vec::with_capacity(task_controls.len());
    for spec in task_controls {
        let kind: controlnet::ControlKind = spec.kind.parse().with_context(|| {
            format!(
                "task {:?}: parsing control kind {:?}",
                task.name, spec.kind
            )
        })?;
        cli_controls.push(controlnet::ControlSpec {
            kind,
            image: spec.image.clone(),
            from: spec.auto_from.clone(),
            strength: spec.strength.unwrap_or(1.0),
            start: spec.start.unwrap_or(0.0),
            end: spec.end.unwrap_or(1.0),
        });
    }
    let req = img2img::Request {
        prompt: gen_req.prompt.clone(),
        negative: gen_req.negative.clone(),
        model: model.to_string(),
        device: device.clone(),
        // v0.14 phase 7: the scenario's t2i Pipeline already has these
        // LoRAs merged into the SdCore we're about to reuse. Pass them
        // along for parity with `img2img::run`'s Request shape, but
        // `run_with_pipeline` doesn't re-merge — the pipeline's
        // weights are authoritative.
        loras: loras.to_vec(),
        lora_scale,
        input: init_image.to_path_buf(),
        mask: mask.map(|p| p.to_path_buf()),
        mask_feather: task.mask_feather.unwrap_or(8),
        mask_invert: task.mask_invert.unwrap_or(false),
        width: gen_req.width,
        height: gen_req.height,
        count: gen_req.count,
        steps: gen_req.steps,
        guidance: gen_req.guidance,
        scheduler,
        strength,
        seed: gen_req.seed,
        out_dir: gen_req.out_dir.clone(),
        controls: cli_controls,
    };
    // v0.14 phase 7: share the t2i Pipeline's SdCore with the
    // img2img runner instead of paying for a second multi-GB load
    // per task. `portrait::Pipeline::from_core` is just an `Arc`
    // clone of the existing core (no identity encoder needed for
    // img2img). Pre-phase-7 each scenario img2img task triggered a
    // fresh `portrait::Pipeline::load` inside `img2img::run`.
    let port = portrait::Pipeline::from_core(sd_pipeline.core());
    img2img::run_with_pipeline(&port, &req).await?;
    Ok(())
}

fn write_flux_anno_png(anno: &candle_core::Tensor, out_path: &std::path::Path) -> Result<()> {
    use candle_core::{DType, IndexOp};
    let (b, c, h, w) = anno.dims4()?;
    if b != 1 || c != 3 {
        anyhow::bail!(
            "annotator output expected shape (1, 3, H, W), got ({b}, {c}, {h}, {w})"
        );
    }
    let scaled = (anno * 255.0)?
        .clamp(0f32, 255f32)?
        .to_dtype(DType::U8)?
        .i(0)?
        .permute((1, 2, 0))?;
    let buf = scaled.flatten_all()?.to_vec1::<u8>()?;
    crate::imaging::io::save_rgb_u8(&buf, w as u32, h as u32, out_path)?;
    Ok(())
}

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

    // v0.15 phase 7a — schema parsing for the new task/scenario fields.

    fn parse_task(src: &str) -> TaskDef {
        deser_hjson::from_str::<TaskDef>(src).expect("task parses")
    }

    /// HJSON requires newline-separated keys (commas are optional but
    /// the parser doesn't reliably consume them inline). The common
    /// task fields `name` / `scene` / `weather` / `prompt` are
    /// required by the deser; we set them to placeholders in every
    /// test.
    const COMMON_TASK: &str = r#"
        name: t
        scene: s
        weather: w
        prompt: p"#;

    #[test]
    fn task_parses_fast_preset() {
        let src = format!(r#"{{{COMMON_TASK}
            fast: hyper-8
        }}"#);
        let t = parse_task(&src);
        assert_eq!(t.fast.as_deref(), Some("hyper-8"));
    }

    #[test]
    fn task_parses_concept_image() {
        let src = format!(r#"{{{COMMON_TASK}
            concept-image: ./edges.png
        }}"#);
        let t = parse_task(&src);
        assert_eq!(
            t.concept_image.as_deref().map(|p| p.to_string_lossy().into_owned()),
            Some("./edges.png".to_string())
        );
    }

    // v0.18 Kontext phase 4 — kontext-bucket HJSON key at task scope.

    #[test]
    fn task_parses_kontext_bucket_true() {
        let src = format!(r#"{{{COMMON_TASK}
            kontext-bucket: true
        }}"#);
        let t = parse_task(&src);
        assert_eq!(t.kontext_bucket, Some(true));
    }

    #[test]
    fn task_parses_kontext_bucket_false() {
        let src = format!(r#"{{{COMMON_TASK}
            kontext-bucket: false
        }}"#);
        let t = parse_task(&src);
        assert_eq!(t.kontext_bucket, Some(false));
    }

    #[test]
    fn task_kontext_bucket_defaults_to_none() {
        // Task without kontext-bucket: parses None → scenario-level
        // value applies; both unset → false. Use the concept-image
        // template as the "non-empty body" so the HJSON parser is
        // happy.
        let src = format!(r#"{{{COMMON_TASK}
            concept-image: ./ref.png
        }}"#);
        let t = parse_task(&src);
        assert_eq!(t.kontext_bucket, None);
    }

    #[test]
    fn task_parses_enhance_false() {
        let src = format!(r#"{{{COMMON_TASK}
            enhance: false
        }}"#);
        let t = parse_task(&src);
        assert!(matches!(t.enhance, Some(EnhanceCfg::Toggle(false))));
    }

    #[test]
    fn task_parses_enhance_provider() {
        let src = format!(r#"{{{COMMON_TASK}
            enhance: deepseek
        }}"#);
        let t = parse_task(&src);
        assert!(matches!(
            t.enhance.as_ref(),
            Some(EnhanceCfg::Provider(p)) if p == "deepseek"
        ));
    }

    #[test]
    fn task_parses_tiled_toggle_false() {
        let src = format!(r#"{{{COMMON_TASK}
            tiled: false
        }}"#);
        let t = parse_task(&src);
        assert!(matches!(t.tiled, Some(TaskTiledCfg::Toggle(false))));
    }

    #[test]
    fn task_parses_tiled_override_block() {
        let src = format!(r#"{{{COMMON_TASK}
            tiled: {{ size: 768, stride: 512 }}
        }}"#);
        let t = parse_task(&src);
        let cfg = match t.tiled {
            Some(TaskTiledCfg::Override(c)) => c,
            other => panic!("expected Override, got {other:?}"),
        };
        assert_eq!(cfg.size, 768);
        assert_eq!(cfg.stride, 512);
    }

    #[test]
    fn task_omitting_new_fields_keeps_them_none() {
        // Backward-compat: every new v0.15 phase 7a field is optional,
        // so an existing scenario task (pre-7a schema) still parses.
        let src = format!("{{{COMMON_TASK}\n}}");
        let t = parse_task(&src);
        assert!(t.fast.is_none());
        assert!(t.concept_image.is_none());
        assert!(t.enhance.is_none());
        assert!(t.tiled.is_none());
    }

    #[test]
    fn scenario_file_parses_fast_at_global() {
        let src = r#"{
            model: flux-dev
            fast: hyper-8
            enhancer: deepseek
            lora-header: ""
        }"#;
        let s = deser_hjson::from_str::<ScenarioFile>(src)
            .expect("scenario parses");
        assert_eq!(s.fast.as_deref(), Some("hyper-8"));
    }

    // v0.25 phase 7 — scenario + per-task look/genre/offline parsing.

    #[test]
    fn scenario_file_parses_look_genre_offline_at_global() {
        let src = r#"{
            model: sdxl
            look: watercolor
            genre: anime
            offline: true
            lora-header: ""
        }"#;
        let s = deser_hjson::from_str::<ScenarioFile>(src)
            .expect("scenario with look/genre parses");
        assert_eq!(s.look.as_deref(), Some("watercolor"));
        assert_eq!(s.genre.as_deref(), Some("anime"));
        assert_eq!(s.offline, Some(true));
    }

    #[test]
    fn scenario_file_look_genre_default_to_none() {
        let src = r#"{
            model: sdxl
            lora-header: ""
        }"#;
        let s = deser_hjson::from_str::<ScenarioFile>(src)
            .expect("scenario parses");
        assert_eq!(s.look, None);
        assert_eq!(s.genre, None);
        assert_eq!(s.offline, None);
    }

    #[test]
    fn task_parses_look_genre_offline() {
        let src = format!(r#"{{{COMMON_TASK}
            look: oil-painting
            genre: anime
            offline: false
        }}"#);
        let t = parse_task(&src);
        assert_eq!(t.look.as_deref(), Some("oil-painting"));
        assert_eq!(t.genre.as_deref(), Some("anime"));
        assert_eq!(t.offline, Some(false));
    }

    #[test]
    fn task_omitting_look_genre_is_none() {
        let src = format!(r#"{{{COMMON_TASK}
        }}"#);
        let t = parse_task(&src);
        assert!(t.look.is_none());
        assert!(t.genre.is_none());
        assert!(t.offline.is_none());
    }

    /// Per-task `look:` overrides scenario-level — verified via the
    /// `.or_else(scenario)` resolution. Pure-data test of the
    /// override expression; the actual apply lives in run_one
    /// (covered by phase 11 integration).
    #[test]
    fn task_look_overrides_scenario_look() {
        let scenario_look = Some("watercolor".to_string());
        let task_look = Some("oil-painting".to_string());
        let eff = task_look.clone().or_else(|| scenario_look.clone());
        assert_eq!(eff.as_deref(), Some("oil-painting"));
    }

    /// Task with no look: inherits from scenario.
    #[test]
    fn task_look_unset_inherits_scenario() {
        let scenario_look = Some("watercolor".to_string());
        let task_look: Option<String> = None;
        let eff = task_look.or_else(|| scenario_look.clone());
        assert_eq!(eff.as_deref(), Some("watercolor"));
    }

    // v0.15 phase 7b-7 — per-task LoRA schema parsing.

    #[test]
    fn task_parses_per_task_loras() {
        let src = format!(r#"{{{COMMON_TASK}
            loras: [ "user/repo:0.7", "./local/style.safetensors" ]
            lora-scale: 0.5
        }}"#);
        let t = parse_task(&src);
        assert_eq!(t.loras.len(), 2);
        assert_eq!(t.loras[0], "user/repo:0.7");
        assert_eq!(t.loras[1], "./local/style.safetensors");
        assert_eq!(t.lora_scale, Some(0.5));
    }

    #[test]
    fn task_omitting_loras_is_empty_vec() {
        let src = format!("{{{COMMON_TASK}\n}}");
        let t = parse_task(&src);
        assert!(t.loras.is_empty());
        assert!(t.lora_scale.is_none());
    }

    #[test]
    fn scenario_file_parses_concept_image_at_global() {
        let src = r#"{
            model: flux-canny-dev
            concept-image: ./edges.png
            enhancer: deepseek
            lora-header: ""
        }"#;
        let s = deser_hjson::from_str::<ScenarioFile>(src)
            .expect("scenario parses");
        assert_eq!(
            s.concept_image.as_deref().map(|p| p.to_string_lossy().into_owned()),
            Some("./edges.png".to_string())
        );
    }

    // v0.16 phase 11 — SD per-task LoRA preflight.

    /// Minimal scenario builder via direct struct construction —
    /// HJSON's brace+newline rules make programmatic scenarios
    /// fiddly. The preflight only reads `tasks[*].loras`, so we
    /// fill those + defaults for the rest.
    fn scenario_with_task_loras(task_loras: &[&[&str]]) -> ScenarioFile {
        let tasks: Vec<TaskDef> = task_loras
            .iter()
            .enumerate()
            .map(|(i, ls)| {
                // Parse a minimal valid task via HJSON to pick up
                // serde defaults on every field we don't set, then
                // override `loras` directly.
                let src = format!(r#"{{
            name: t{i}
            scene: s
            weather: w
            prompt: p
        }}"#);
                let mut t: TaskDef = deser_hjson::from_str(&src)
                    .expect("task parses");
                t.loras = ls.iter().map(|s| s.to_string()).collect();
                t
            })
            .collect();
        // Empty scenario picks up all-default fields.
        let mut s: ScenarioFile = deser_hjson::from_str(
            r#"{
            lora-header: ""
        }"#,
        )
        .expect("scenario parses");
        s.tasks = tasks;
        s
    }

    #[test]
    fn preflight_no_per_task_loras_passes() {
        // Tasks declare no per-task LoRAs → preflight is silent.
        let s = scenario_with_task_loras(&[&[], &[], &[]]);
        sd_per_task_lora_preflight(&s, "sd15").expect("no LoRAs → ok");
        sd_per_task_lora_preflight(&s, "sdxl").expect("no LoRAs → ok");
    }

    #[test]
    fn preflight_uniform_per_task_loras_emits_hint_but_passes() {
        // Every task uses the SAME LoRA stack. The preflight prints a
        // hint to fold to scenario-level loras: but doesn't bail —
        // the user's scenario is salvageable by moving the per-task
        // block up; we don't want to block them from doing that
        // themselves.
        let s = scenario_with_task_loras(&[
            &["foo/lora-a:0.7"],
            &["foo/lora-a:0.7"],
            &["foo/lora-a:0.7"],
        ]);
        sd_per_task_lora_preflight(&s, "sd15").expect("uniform stacks → hint only");
    }

    #[test]
    fn preflight_varying_per_task_loras_bails() {
        // Two tasks with different LoRA stacks → bail loud upfront
        // with the three-option workaround.
        let s = scenario_with_task_loras(&[
            &["foo/lora-a:0.7"],
            &["bar/lora-b:0.5"],
        ]);
        let err = sd_per_task_lora_preflight(&s, "sdxl").unwrap_err();
        let msg = format!("{err}");
        assert!(msg.contains("vary across"), "got {msg}");
        assert!(msg.contains("Flux"), "got {msg}");
        assert!(msg.contains("Split"), "got {msg}");
    }

    #[test]
    fn preflight_partial_per_task_loras_bails() {
        // Some tasks declare LoRAs, others don't. Even if the
        // declaring tasks all use the same stack, the asymmetry
        // means we can't fold cleanly — bail.
        let s = scenario_with_task_loras(&[
            &["foo/lora-a:0.7"],
            &[],
            &["foo/lora-a:0.7"],
        ]);
        let err = sd_per_task_lora_preflight(&s, "sd15").unwrap_err();
        let msg = format!("{err}");
        assert!(msg.contains("vary across"), "got {msg}");
    }

    #[test]
    fn preflight_flux_model_skips() {
        // Flux supports runtime per-task LoRA — preflight is a
        // silent no-op even with varying stacks.
        let s = scenario_with_task_loras(&[
            &["foo/lora-a:0.7"],
            &["bar/lora-b:0.5"],
        ]);
        sd_per_task_lora_preflight(&s, "flux-dev").expect("Flux supports runtime LoRA");
    }

    #[test]
    fn preflight_sd3_model_skips() {
        let s = scenario_with_task_loras(&[
            &["foo/lora-a:0.7"],
            &["bar/lora-b:0.5"],
        ]);
        sd_per_task_lora_preflight(&s, "sd35-medium")
            .expect("SD3 supports runtime LoRA");
    }

    // v0.17 phase 5 — task_outputs_all_present probe.

    fn touch(dir: &std::path::Path, name: &str) {
        std::fs::write(dir.join(name), b"x").unwrap();
    }

    #[test]
    fn resume_probe_returns_false_when_dir_missing() {
        let tmp = tempfile::tempdir().unwrap();
        let missing = tmp.path().join("nonexistent");
        assert!(!task_outputs_all_present(&missing, 1000, 2));
    }

    #[test]
    fn resume_probe_returns_false_when_some_files_missing() {
        let tmp = tempfile::tempdir().unwrap();
        touch(tmp.path(), "plakat-1000.png");
        // missing plakat-1001.png
        assert!(!task_outputs_all_present(tmp.path(), 1000, 2));
    }

    #[test]
    fn resume_probe_matches_plakat_prefix() {
        let tmp = tempfile::tempdir().unwrap();
        touch(tmp.path(), "plakat-1000.png");
        touch(tmp.path(), "plakat-1001.png");
        assert!(task_outputs_all_present(tmp.path(), 1000, 2));
    }

    #[test]
    fn resume_probe_matches_flux_prefix() {
        let tmp = tempfile::tempdir().unwrap();
        touch(tmp.path(), "plakat-flux-2000.png");
        touch(tmp.path(), "plakat-flux-2001.png");
        assert!(task_outputs_all_present(tmp.path(), 2000, 2));
    }

    #[test]
    fn resume_probe_matches_sd3_prefix() {
        let tmp = tempfile::tempdir().unwrap();
        touch(tmp.path(), "plakat-sd3-3000.png");
        assert!(task_outputs_all_present(tmp.path(), 3000, 1));
    }

    #[test]
    fn resume_probe_zero_count_returns_false() {
        // Defensive: count == 0 means "no expected outputs" — the
        // empty `for i in 0..0` vacuously satisfies all() (returns
        // true for an empty iterator). Skip the task body via the
        // up-front `count == 0` guard instead.
        let tmp = tempfile::tempdir().unwrap();
        assert!(!task_outputs_all_present(tmp.path(), 1000, 0));
    }

    // v0.20 #5: `validate_enhancer_keys` should pass providers
    // that don't require a cloud API key without consulting the
    // env. The previous version of the gate rejected `local`,
    // which made `plakat init`-generated scenarios non-runnable
    // out of the box.

    #[test]
    fn validate_enhancer_keys_accepts_local() {
        validate_enhancer_keys("local").unwrap();
        validate_enhancer_keys("LOCAL").unwrap();
    }

    #[test]
    fn validate_enhancer_keys_accepts_local_alias() {
        validate_enhancer_keys("local:qwen2.5-1.5b").unwrap();
        validate_enhancer_keys("local:smollm2-360m").unwrap();
    }

    #[test]
    fn validate_enhancer_keys_accepts_auto() {
        validate_enhancer_keys("auto").unwrap();
    }

    #[test]
    fn validate_enhancer_keys_rejects_unknown() {
        let err = validate_enhancer_keys("openai-gpt").unwrap_err();
        let msg = format!("{err}");
        assert!(msg.contains("unknown enhancer"), "got {msg}");
        // Error message advertises every supported form so users
        // can self-correct without grepping the source.
        assert!(msg.contains("local"), "got {msg}");
        assert!(msg.contains("auto"), "got {msg}");
    }
}
