//! MAP-4 — the **`map` scenario task**. Lets a `plakat scenario` batch (and, via
//! compile, a `prompts.txt`) produce a map alongside renders/animations. This is
//! the delegate the scenario loop calls; it keeps the big `cli/scenario.rs`
//! dispatch thin — source the spec (load or LLM-parse), then render linework
//! (deterministic, no GPU) or paint with SD (the 1.6 `render_sd` path).

use anyhow::{Context, Result};
use candle_core::Device;
use std::path::{Path, PathBuf};

use super::parser::{self, ParseOpts};
use super::render::Style;
use super::render_sd::{self, SdOptions};
use super::spec::{MapSpec, SPEC_VERSION};

/// Everything a `map` scenario task needs, already merged (scenario ⊕ task).
#[derive(Debug, Clone)]
pub struct MapTaskCfg {
    /// Prose world description (the task `prompt`). Ignored when `spec_path` is set.
    pub description: String,
    /// A committed `MapSpec` JSON to load (skips the LLM) — the deterministic path.
    pub spec_path: Option<PathBuf>,
    /// `parchment` | `inked` | `blueprint`.
    pub style: String,
    /// Paint with SD (img2img + Canny) vs the deterministic linework render.
    pub paint: bool,
    /// LLM provider for the prose→spec parse (reuses the `--enhance` stack).
    pub provider: String,
    /// `--map-scale` alias, e.g. `region`.
    pub scale: Option<String>,
    /// `--map-tiles` `CxR`.
    pub tiles: Option<String>,
    /// SD backbone for `paint` (any plakat model).
    pub sd_model: String,
    /// LoRA specs for `paint`; empty = the model's default (fantasy-map for SDXL).
    pub sd_loras: Vec<String>,
    /// Override the town street plan (`radial`/`grid`/`organic`).
    pub urban_layout: Option<String>,
    /// Override natural-feature erosion (0 smooth … 1 natural … >1 rugged).
    pub erosion: Option<f32>,
    /// 1.14.0-B: emit the world as a grid of seamless tiles (`tile_r{R}_c{C}.png`
    /// + `world.png`) over `spec.tile_grid`, instead of the single `map.png`.
    /// Mirrors the CLI `--map-render-tiles`. Linework only (the deterministic path).
    pub render_tiles: bool,
    /// 1.14.0-D: with `render_tiles`, draw per-tile furniture (frame + grid
    /// coordinate + north arrow). Mirrors `--map-tile-furniture`.
    pub render_tile_furniture: bool,
    /// SHA-256 cache the parsed spec.
    pub cache: bool,
}

impl Default for MapTaskCfg {
    fn default() -> Self {
        MapTaskCfg {
            description: String::new(),
            spec_path: None,
            style: "parchment".into(),
            paint: false,
            provider: "auto".into(),
            scale: None,
            tiles: None,
            sd_model: "sdxl".into(),
            sd_loras: Vec::new(),
            urban_layout: None,
            erosion: None,
            render_tiles: false,
            render_tile_furniture: false,
            cache: false,
        }
    }
}

impl MapTaskCfg {
    /// Resolve the LoRA set: explicit list (with `none` → none) else the model's
    /// default (fantasy-map for SDXL-family, none otherwise).
    fn resolved_loras(&self) -> Vec<String> {
        if self.sd_loras.is_empty() {
            render_sd::default_loras_for_model(&self.sd_model)
        } else if self.sd_loras.iter().any(|s| s.eq_ignore_ascii_case("none")) {
            Vec::new()
        } else {
            self.sd_loras.clone()
        }
    }
}

/// Source the spec for a map task: load `spec_path` (no LLM) or parse the prose.
/// Pure of rendering — used by both the run path and `--dry-run` validation.
pub async fn source_spec(cfg: &MapTaskCfg) -> Result<MapSpec> {
    let (grid, tier) = super::resolve_scale(cfg.tiles.as_deref(), cfg.scale.as_deref())?;
    if let Some(p) = &cfg.spec_path {
        let text = std::fs::read_to_string(p)
            .with_context(|| format!("reading map task --map-spec {}", p.display()))?;
        let mut m: MapSpec =
            serde_json::from_str(&text).with_context(|| format!("parsing MapSpec {}", p.display()))?;
        if m.version != SPEC_VERSION {
            tracing::warn!(target: "plakat", "map task: spec version {} (expected {}) — best-effort", m.version, SPEC_VERSION);
        }
        if let Some(g) = grid {
            m.tile_grid = g;
        }
        if let Some(t) = tier {
            m.scale_tier = t;
        }
        Ok(m)
    } else {
        if cfg.description.trim().is_empty() {
            anyhow::bail!("map task: provide a description (prompt) or a `map-spec` path");
        }
        parser::parse(
            &cfg.description,
            &ParseOpts {
                provider: cfg.provider.clone(),
                system_override: None,
                tile_grid: grid,
                scale_tier: tier,
                cache: cfg.cache,
            },
        )
        .await
    }
}

/// Run a `map` scenario task: source the spec, then render to `out_dir/map.png`.
/// `dry_run` validates the spec source without rendering. Returns the output path.
pub async fn run_map_task(
    cfg: &MapTaskCfg,
    seed: u64,
    device: Device,
    out_dir: &Path,
    dry_run: bool,
) -> Result<PathBuf> {
    let mut spec = source_spec(cfg).await?;
    if let Some(l) = &cfg.urban_layout {
        spec.urban.get_or_insert_with(Default::default).layout = Some(l.clone());
    }
    if let Some(e) = cfg.erosion {
        spec.terrain.erosion = Some(e);
    }
    let style = Style::named(&cfg.style)?;
    // Tiled output: the deliverable is the sliced world (world.png + tiles), not
    // the single map.png. Primary path reported back is world.png.
    let out = if cfg.render_tiles {
        out_dir.join("world.png")
    } else {
        out_dir.join("map.png")
    };
    if dry_run {
        return Ok(out);
    }
    std::fs::create_dir_all(out_dir)
        .with_context(|| format!("creating map task out dir {}", out_dir.display()))?;

    if cfg.render_tiles {
        let n = super::save_world_tiles(&spec, seed, style, out_dir, cfg.render_tile_furniture)
            .context("map task: tiled world render")?;
        tracing::info!(target: "plakat", "map task: tiled world → {n} tile(s) + world.png");
    } else if cfg.paint {
        let opts = SdOptions {
            model: cfg.sd_model.clone(),
            loras: cfg.resolved_loras(),
            ..SdOptions::default()
        };
        render_sd::render_sd(&spec, seed, style, &opts, device, &out)
            .await
            .context("map task: SD painted render")?;
    } else {
        super::save_map_image(&spec, seed, style, &out).context("map task: linework render")?;
    }
    Ok(out)
}

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

    fn island_cfg() -> MapTaskCfg {
        MapTaskCfg {
            spec_path: Some(PathBuf::from("corpus/map/island.spec.json")),
            ..MapTaskCfg::default()
        }
    }

    #[tokio::test]
    async fn sources_committed_spec_without_llm() {
        let m = source_spec(&island_cfg()).await.unwrap();
        assert_eq!(m.name, "The Isle of Vethûn");
        assert_eq!(m.landmarks.len(), 4);
    }

    #[tokio::test]
    async fn dry_run_validates_without_rendering() {
        let out = run_map_task(&island_cfg(), 42, Device::Cpu, Path::new("/tmp/plakat-noexist-xyz"), true)
            .await
            .unwrap();
        assert!(out.ends_with("map.png"));
        assert!(!out.exists(), "dry-run must not write a file");
    }

    #[tokio::test]
    async fn linework_render_writes_a_png() {
        let dir = std::env::temp_dir().join("plakat-map-task-test");
        let _ = std::fs::remove_dir_all(&dir);
        let out = run_map_task(&island_cfg(), 42, Device::Cpu, &dir, false).await.unwrap();
        assert!(out.exists(), "linework render writes map.png");
        let img = image::open(&out).unwrap();
        assert!(img.width() > 0 && img.height() > 0);
        let _ = std::fs::remove_dir_all(&dir);
    }

    fn coastal_cfg() -> MapTaskCfg {
        MapTaskCfg {
            spec_path: Some(PathBuf::from("corpus/map/coastal.spec.json")),
            ..MapTaskCfg::default()
        }
    }

    #[tokio::test]
    async fn coastal_terrain_features_flow_through_source_spec() {
        // 1.14.0-B: the coastal terrain (peninsulas/inlets/fjords) added in 1.13.0
        // lives in the spec, so any surface that loads a spec gets it. Verify the
        // fields survive the load path the scenario/scripting/compile surfaces use.
        let m = source_spec(&coastal_cfg()).await.unwrap();
        assert_eq!(m.terrain.peninsulas.len(), 1, "peninsula survives load");
        assert_eq!(m.terrain.inlets.len(), 1, "inlet survives load");
        assert_eq!(m.terrain.fjords.len(), 2, "fjords survive load");
        assert_eq!(m.terrain.fjords[0].id, "cold_arm");
    }

    #[tokio::test]
    async fn coastal_spec_renders_linework() {
        let dir = std::env::temp_dir().join("plakat-coastal-render-test");
        let _ = std::fs::remove_dir_all(&dir);
        let out = run_map_task(&coastal_cfg(), 7, Device::Cpu, &dir, false).await.unwrap();
        assert!(out.exists(), "coastal linework render writes map.png");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn tiled_task_output_matches_shared_helper_byte_for_byte() {
        // 1.14.0-B: the `map-render-tiles` task path and the CLI both dispatch the
        // shared `save_world_tiles`, so their tiles are byte-identical (parity).
        let base = std::env::temp_dir().join("plakat-tile-parity-test");
        let _ = std::fs::remove_dir_all(&base);
        let via_task = base.join("task");
        let via_helper = base.join("helper");

        let cfg = MapTaskCfg { render_tiles: true, ..coastal_cfg() };
        let out = run_map_task(&cfg, 7, Device::Cpu, &via_task, false).await.unwrap();
        assert!(out.ends_with("world.png"), "tiled task reports world.png");

        let spec = source_spec(&coastal_cfg()).await.unwrap();
        let style = Style::named("parchment").unwrap();
        let n = super::super::save_world_tiles(&spec, 7, style, &via_helper, false).unwrap();
        assert_eq!(n, 4, "2x2 grid → 4 tiles");

        for name in ["world.png", "tile_r0_c0.png", "tile_r0_c1.png", "tile_r1_c0.png", "tile_r1_c1.png"] {
            let a = std::fs::read(via_task.join(name)).unwrap();
            let b = std::fs::read(via_helper.join(name)).unwrap();
            assert_eq!(a, b, "{name} byte-identical between task and shared helper");
        }
        let _ = std::fs::remove_dir_all(&base);
    }

    #[test]
    fn lora_resolution_matches_cli_semantics() {
        let mut c = MapTaskCfg { sd_model: "sdxl".into(), ..MapTaskCfg::default() };
        assert_eq!(c.resolved_loras(), vec!["Muapi/fantasy-map".to_string()]);
        c.sd_model = "sd15".into();
        assert!(c.resolved_loras().is_empty(), "non-SDXL → no default LoRA");
        c.sd_loras = vec!["none".into()];
        assert!(c.resolved_loras().is_empty(), "explicit none disables");
        c.sd_loras = vec!["user/custom".into()];
        assert_eq!(c.resolved_loras(), vec!["user/custom".to_string()]);
    }
}
