use std::collections::{BTreeMap, BTreeSet};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::{fs, io};

use bone_kernel::{BrepSolid, ExtrudeFeature};
use bone_types::{
    AngleTolerance, BodyId, ChordHeightTolerance, ExtrudeId, SchemaHeader, SchemaVersion, SketchId,
};

use crate::document::{
    Document, DocumentHeader, ExtrudeFile, FeatureNode, ImportedSolid, SketchFile,
    body_brep_filename, body_labels_filename, extrude_filename, sketch_filename,
};
use crate::io::blob::{BlobHash, BlobKind};
use crate::io::labels::LabelSidecar;
use crate::io::ron_io::{RonError, from_str, to_string};
use crate::sketch::SketchEditError;

pub const DOCUMENT_FILE: &str = "document.ron";
pub const SKETCHES_DIR: &str = "sketches";
pub const EXTRUDES_DIR: &str = "extrudes";
pub const BODIES_DIR: &str = "bodies";
pub const BLOBS_DIR: &str = "blobs";
pub const CACHES_DIR: &str = "caches";
pub const TESSELLATIONS_DIR: &str = "tessellations";

const ROOT_GITIGNORE: &str = "caches/\n";
const ROOT_GITATTRIBUTES: &str = "* text=auto eol=lf\n*.brep text eol=lf\n*.labels text eol=lf\n";
const CACHES_GITIGNORE: &str = "*\n!.gitignore\n!CACHEDIR.TAG\n";
const CACHEDIR_TAG: &str = concat!(
    "Signature: 8a477f597d28d172789f06886806bc55\n",
    "# This file is a cache directory tag automatically created by bone.\n",
    "# For information about cache directory tags see https://bford.info/cachedir/\n",
);

#[derive(Debug, thiserror::Error)]
#[error(transparent)]
pub struct FolderError(Box<FolderErrorKind>);

impl FolderError {
    #[must_use]
    pub fn kind(&self) -> &FolderErrorKind {
        &self.0
    }

    #[must_use]
    pub fn into_kind(self) -> FolderErrorKind {
        *self.0
    }
}

impl From<FolderErrorKind> for FolderError {
    fn from(kind: FolderErrorKind) -> Self {
        Self(Box::new(kind))
    }
}

impl FolderErrorKind {
    fn wrap(self) -> FolderError {
        FolderError(Box::new(self))
    }
}

#[derive(Debug, thiserror::Error)]
pub enum FolderErrorKind {
    #[error("io at {path}: {source}")]
    Io {
        path: PathBuf,
        #[source]
        source: io::Error,
    },
    #[error("ron at {path}: {source}")]
    Ron {
        path: PathBuf,
        #[source]
        source: RonError,
    },
    #[error("unknown schema {found} (expected name {expected_name})")]
    UnknownSchema {
        found: String,
        expected_name: &'static str,
    },
    #[error("schema {name} major v{found} is unsupported (this build supports v{supported})")]
    UnsupportedMajor {
        name: String,
        found: SchemaVersion,
        supported: SchemaVersion,
    },
    #[error("registry references sketch {id:?} with no file on disk")]
    MissingSketchFile { id: SketchId },
    #[error("integrity of {path}: {source}")]
    SketchIntegrity {
        path: PathBuf,
        #[source]
        source: SketchEditError,
    },
    #[error("feature tree references sketch {id:?} not in registry")]
    DanglingTreeSketch { id: SketchId },
    #[error("registry has sketch {id:?} absent from feature tree")]
    OrphanRegistered { id: SketchId },
    #[error("feature tree references extrude {id:?} with no file on disk")]
    MissingExtrudeFile { id: ExtrudeId },
    #[error("feature tree references imported body {id:?} with no blob on disk")]
    MissingBodyFile { id: BodyId },
    #[error("stored extrude {extrude:?} references sketch {sketch:?} absent from the document")]
    DanglingExtrudeSketch {
        extrude: ExtrudeId,
        sketch: SketchId,
    },
    #[error("geometry blob at {path}: {source}")]
    Blob {
        path: PathBuf,
        #[source]
        source: bone_kernel::BrepError,
    },
    #[error("geometry blob at {path} hashes to {found}, expected {expected}")]
    BlobHashMismatch {
        path: PathBuf,
        found: String,
        expected: String,
    },
    #[error("label sidecar at {path} does not match its geometry blob")]
    SidecarMismatch { path: PathBuf },
}

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct DocumentFolder {
    path: PathBuf,
}

impl DocumentFolder {
    #[must_use]
    pub fn new(path: impl Into<PathBuf>) -> Self {
        Self { path: path.into() }
    }

    #[must_use]
    pub fn path(&self) -> &Path {
        &self.path
    }

    #[must_use]
    pub fn document_file(&self) -> PathBuf {
        self.path.join(DOCUMENT_FILE)
    }

    #[must_use]
    pub fn sketches_dir(&self) -> PathBuf {
        self.path.join(SKETCHES_DIR)
    }

    #[must_use]
    pub fn extrudes_dir(&self) -> PathBuf {
        self.path.join(EXTRUDES_DIR)
    }

    #[must_use]
    pub fn bodies_dir(&self) -> PathBuf {
        self.path.join(BODIES_DIR)
    }

    #[must_use]
    pub fn blobs_dir(&self) -> PathBuf {
        self.path.join(BLOBS_DIR)
    }

    #[must_use]
    pub fn caches_dir(&self) -> PathBuf {
        self.path.join(CACHES_DIR)
    }

    #[must_use]
    pub fn sketch_path(&self, id: SketchId) -> PathBuf {
        self.sketches_dir().join(sketch_filename(id))
    }

    #[must_use]
    pub fn extrude_path(&self, id: ExtrudeId) -> PathBuf {
        self.extrudes_dir().join(extrude_filename(id))
    }

    #[must_use]
    pub fn body_brep_path(&self, id: BodyId) -> PathBuf {
        self.bodies_dir().join(body_brep_filename(id))
    }

    #[must_use]
    pub fn body_labels_path(&self, id: BodyId) -> PathBuf {
        self.bodies_dir().join(body_labels_filename(id))
    }

    #[must_use]
    pub fn blob_path(&self, hash: BlobHash, kind: BlobKind) -> PathBuf {
        self.blobs_dir().join(hash.relative_path(kind))
    }

    #[must_use]
    pub fn tessellation_path(
        &self,
        hash: BlobHash,
        chord: ChordHeightTolerance,
        angle: AngleTolerance,
    ) -> PathBuf {
        self.caches_dir().join(TESSELLATIONS_DIR).join(format!(
            "{}.{}.{}",
            hash.truncated_128_hex(),
            tessellation_tier_hex(chord, angle),
            BlobKind::TESS.as_str()
        ))
    }
}

fn tessellation_tier_hex(chord: ChordHeightTolerance, angle: AngleTolerance) -> String {
    format!(
        "{:016x}{:016x}",
        chord.millimeters().to_bits(),
        angle.radians().to_bits()
    )
}

pub fn save(document: &Document, folder: &DocumentFolder) -> Result<(), FolderError> {
    ensure_scaffold(folder)?;

    document
        .sketches()
        .try_for_each(|(id, sketch)| -> Result<(), FolderError> {
            let file = SketchFile::new(sketch.clone());
            let ron = to_ron(&folder.sketch_path(id), &file)?;
            write_if_different(&folder.sketch_path(id), &ron)
        })?;

    let tree_extrudes = tree_extrude_ids(document.header());
    document
        .header()
        .extrudes
        .iter()
        .filter(|(id, _)| tree_extrudes.contains(*id))
        .try_for_each(|(id, feature)| -> Result<(), FolderError> {
            let path = folder.extrude_path(*id);
            let label = document.extrude_label(*id).unwrap_or_default().to_owned();
            let ron = to_ron(&path, &ExtrudeFile::new(*feature, label))?;
            write_if_different(&path, &ron)
        })?;

    document
        .imported_bodies()
        .try_for_each(|(id, solid)| write_body(folder, id, solid))?;

    let document_ron = to_ron(&folder.document_file(), document.header())?;
    write_if_different(&folder.document_file(), &document_ron)?;

    let live_sketches = document
        .registry()
        .order()
        .iter()
        .copied()
        .map(sketch_filename)
        .collect();
    remove_stale_files(&folder.sketches_dir(), &live_sketches, is_ron)?;
    let live_extrudes = tree_extrudes
        .iter()
        .copied()
        .map(extrude_filename)
        .collect();
    remove_stale_files(&folder.extrudes_dir(), &live_extrudes, is_ron)?;
    let live_bodies = tree_body_ids(document.header())
        .iter()
        .flat_map(|id| [body_brep_filename(*id), body_labels_filename(*id)])
        .collect();
    remove_stale_files(&folder.bodies_dir(), &live_bodies, is_body_blob)?;

    Ok(())
}

fn write_body(folder: &DocumentFolder, id: BodyId, solid: &BrepSolid) -> Result<(), FolderError> {
    let brep_path = folder.body_brep_path(id);
    let labels_path = folder.body_labels_path(id);
    let blob = solid.to_blob().map_err(|source| {
        FolderErrorKind::Blob {
            path: brep_path.clone(),
            source,
        }
        .wrap()
    })?;
    let sidecar = LabelSidecar::capture(solid).to_ron().map_err(|source| {
        FolderErrorKind::Ron {
            path: labels_path.clone(),
            source,
        }
        .wrap()
    })?;
    write_bytes_if_different(&brep_path, &blob)?;
    write_bytes_if_different(&labels_path, sidecar.as_bytes())
}

fn is_ron(ext: &str) -> bool {
    ext.eq_ignore_ascii_case("ron")
}

fn is_body_blob(ext: &str) -> bool {
    ext.eq_ignore_ascii_case("brep") || ext.eq_ignore_ascii_case("labels")
}

fn tree_extrude_ids(header: &DocumentHeader) -> BTreeSet<ExtrudeId> {
    header
        .feature_tree
        .iter()
        .filter_map(|(_, node)| match node {
            FeatureNode::Extrude(id) => Some(id),
            FeatureNode::Origin
            | FeatureNode::PrincipalPlane(_)
            | FeatureNode::Sketch(_)
            | FeatureNode::ImportedBody(_) => None,
        })
        .collect()
}

fn tree_body_ids(header: &DocumentHeader) -> BTreeSet<BodyId> {
    header
        .feature_tree
        .iter()
        .filter_map(|(_, node)| match node {
            FeatureNode::ImportedBody(id) => Some(id),
            FeatureNode::Origin
            | FeatureNode::PrincipalPlane(_)
            | FeatureNode::Sketch(_)
            | FeatureNode::Extrude(_) => None,
        })
        .collect()
}

pub fn load(folder: &DocumentFolder) -> Result<Document, FolderError> {
    let header_path = folder.document_file();
    let header_text = read_to_string(&header_path)?;
    let mut header: DocumentHeader = from_ron(&header_path, &header_text)?;
    check_schema(&header.schema)?;
    let (extrudes, extrude_labels) = read_extrudes(folder, &header)?;
    header.extrudes = extrudes;
    header.extrude_labels = extrude_labels;
    validate_header(&header)?;

    let sketches =
        header
            .sketches
            .order()
            .iter()
            .copied()
            .try_fold(BTreeMap::new(), |mut acc, id| {
                let path = folder.sketch_path(id);
                let text = read_to_string(&path).map_err(|e| match e.into_kind() {
                    FolderErrorKind::Io { source, .. }
                        if source.kind() == io::ErrorKind::NotFound =>
                    {
                        FolderErrorKind::MissingSketchFile { id }.wrap()
                    }
                    other => other.wrap(),
                })?;
                let file: SketchFile = from_ron(&path, &text)?;
                check_schema(&file.schema)?;
                file.sketch.validate().map_err(|source| {
                    FolderErrorKind::SketchIntegrity {
                        path: path.clone(),
                        source,
                    }
                    .wrap()
                })?;
                acc.insert(id, file.sketch);
                Ok::<_, FolderError>(acc)
            })?;

    let bodies = read_bodies(folder, &header)?;

    Ok(Document::from_parts(header, sketches, bodies))
}

fn read_bodies(
    folder: &DocumentFolder,
    header: &DocumentHeader,
) -> Result<BTreeMap<BodyId, ImportedSolid>, FolderError> {
    tree_body_ids(header)
        .into_iter()
        .try_fold(BTreeMap::new(), |mut acc, id| {
            acc.insert(id, ImportedSolid::new(read_body(folder, id)?));
            Ok::<_, FolderError>(acc)
        })
}

fn read_body(folder: &DocumentFolder, id: BodyId) -> Result<BrepSolid, FolderError> {
    let brep_path = folder.body_brep_path(id);
    let labels_path = folder.body_labels_path(id);
    let blob = read_bytes(&brep_path).map_err(|e| missing_body(e, id))?;
    let sidecar_text = read_to_string(&labels_path).map_err(|e| missing_body(e, id))?;
    let sidecar = LabelSidecar::from_ron(&sidecar_text).map_err(|source| {
        FolderErrorKind::Ron {
            path: labels_path.clone(),
            source,
        }
        .wrap()
    })?;
    let solid = BrepSolid::from_blob(&blob, sidecar.reattach()).map_err(|source| {
        FolderErrorKind::Blob {
            path: brep_path,
            source,
        }
        .wrap()
    })?;
    if sidecar.matches(solid.content_key()) {
        Ok(solid)
    } else {
        Err(FolderErrorKind::SidecarMismatch { path: labels_path }.wrap())
    }
}

fn missing_body(error: FolderError, id: BodyId) -> FolderError {
    match error.into_kind() {
        FolderErrorKind::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => {
            FolderErrorKind::MissingBodyFile { id }.wrap()
        }
        other => other.wrap(),
    }
}

type ExtrudeData = (
    BTreeMap<ExtrudeId, ExtrudeFeature>,
    BTreeMap<ExtrudeId, String>,
);

fn read_extrudes(
    folder: &DocumentFolder,
    header: &DocumentHeader,
) -> Result<ExtrudeData, FolderError> {
    tree_extrude_ids(header).into_iter().try_fold(
        (BTreeMap::new(), BTreeMap::new()),
        |(mut features, mut labels), id| {
            let path = folder.extrude_path(id);
            let text = read_to_string(&path).map_err(|e| match e.into_kind() {
                FolderErrorKind::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => {
                    FolderErrorKind::MissingExtrudeFile { id }.wrap()
                }
                other => other.wrap(),
            })?;
            let file: ExtrudeFile = from_ron(&path, &text)?;
            check_schema(&file.schema)?;
            features.insert(id, file.feature);
            labels.insert(id, file.label);
            Ok::<_, FolderError>((features, labels))
        },
    )
}

fn validate_header(header: &DocumentHeader) -> Result<(), FolderError> {
    let tree = &header.feature_tree;

    let registered: BTreeSet<SketchId> = header.sketches.order().iter().copied().collect();
    let tree_sketches: BTreeSet<SketchId> = tree
        .iter()
        .filter_map(|(_, node)| match node {
            FeatureNode::Sketch(id) => Some(id),
            FeatureNode::Origin
            | FeatureNode::PrincipalPlane(_)
            | FeatureNode::Extrude(_)
            | FeatureNode::ImportedBody(_) => None,
        })
        .collect();
    if let Some(&id) = tree_sketches.difference(&registered).next() {
        return Err(FolderErrorKind::DanglingTreeSketch { id }.wrap());
    }
    if let Some(&id) = registered.difference(&tree_sketches).next() {
        return Err(FolderErrorKind::OrphanRegistered { id }.wrap());
    }

    if let Some((&extrude, feature)) = header
        .extrudes
        .iter()
        .find(|(_, feature)| !registered.contains(&feature.sketch))
    {
        return Err(FolderErrorKind::DanglingExtrudeSketch {
            extrude,
            sketch: feature.sketch,
        }
        .wrap());
    }

    Ok(())
}

fn check_schema(schema: &SchemaHeader) -> Result<(), FolderError> {
    if !schema.is_bone_document() {
        return Err(FolderErrorKind::UnknownSchema {
            found: schema.name.clone(),
            expected_name: SchemaHeader::BONE_DOCUMENT_NAME,
        }
        .wrap());
    }
    let supported = SchemaVersion::new(
        SchemaHeader::BONE_DOCUMENT_MAJOR,
        SchemaHeader::BONE_DOCUMENT_MINOR,
    );
    if schema.version.major != SchemaHeader::BONE_DOCUMENT_MAJOR {
        return Err(FolderErrorKind::UnsupportedMajor {
            name: schema.name.clone(),
            found: schema.version,
            supported,
        }
        .wrap());
    }
    if schema.version.minor > SchemaHeader::BONE_DOCUMENT_MINOR {
        tracing::info!(
            name = %schema.name,
            found = %schema.version,
            supported = %supported,
            "accepting a newer minor schema version than this build writes"
        );
    }
    Ok(())
}

pub(crate) fn ensure_scaffold(folder: &DocumentFolder) -> Result<(), FolderError> {
    write_if_different(&folder.path().join(".gitignore"), ROOT_GITIGNORE)?;
    write_if_different(&folder.path().join(".gitattributes"), ROOT_GITATTRIBUTES)?;
    write_if_different(&folder.caches_dir().join("CACHEDIR.TAG"), CACHEDIR_TAG)?;
    write_if_different(&folder.caches_dir().join(".gitignore"), CACHES_GITIGNORE)
}

pub(crate) fn ensure_dir(path: &Path) -> Result<(), FolderError> {
    fs::create_dir_all(path).map_err(|source| {
        FolderErrorKind::Io {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })
}

fn write_if_different(path: &Path, contents: &str) -> Result<(), FolderError> {
    if let Ok(existing) = fs::read_to_string(path)
        && existing == contents
    {
        return Ok(());
    }
    atomic_write(path, contents)
}

fn write_bytes_if_different(path: &Path, contents: &[u8]) -> Result<(), FolderError> {
    if let Ok(existing) = fs::read(path)
        && existing == contents
    {
        return Ok(());
    }
    atomic_write_bytes(path, contents)
}

pub(crate) fn write_if_absent(path: &Path, contents: &[u8]) -> Result<(), FolderError> {
    match path.try_exists() {
        Ok(true) => Ok(()),
        Ok(false) => atomic_write_bytes(path, contents),
        Err(source) => Err(FolderErrorKind::Io {
            path: path.to_path_buf(),
            source,
        }
        .wrap()),
    }
}

fn atomic_write(path: &Path, contents: &str) -> Result<(), FolderError> {
    atomic_write_bytes(path, contents.as_bytes())
}

#[derive(Copy, Clone)]
enum Durability {
    Fsync,
    Fast,
}

pub(crate) fn atomic_write_bytes(path: &Path, contents: &[u8]) -> Result<(), FolderError> {
    atomic_write_with(path, contents, Durability::Fsync)
}

pub(crate) fn atomic_write_cache(path: &Path, contents: &[u8]) -> Result<(), FolderError> {
    atomic_write_with(path, contents, Durability::Fast)
}

fn atomic_write_with(
    path: &Path,
    contents: &[u8],
    durability: Durability,
) -> Result<(), FolderError> {
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    ensure_dir(parent)?;
    let tmp = tmp_sibling(path);
    write_tmp(&tmp, contents, durability)?;
    fs::rename(&tmp, path).map_err(|source| {
        let _ = fs::remove_file(&tmp);
        FolderErrorKind::Io {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })?;
    match durability {
        Durability::Fsync => sync_dir(parent),
        Durability::Fast => Ok(()),
    }
}

fn write_tmp(path: &Path, contents: &[u8], durability: Durability) -> Result<(), FolderError> {
    let mut file = fs::File::create(path).map_err(|source| {
        FolderErrorKind::Io {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })?;
    file.write_all(contents).map_err(|source| {
        FolderErrorKind::Io {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })?;
    match durability {
        Durability::Fsync => file.sync_all().map_err(|source| {
            FolderErrorKind::Io {
                path: path.to_path_buf(),
                source,
            }
            .wrap()
        }),
        Durability::Fast => Ok(()),
    }
}

pub(crate) fn read_bytes(path: &Path) -> Result<Vec<u8>, FolderError> {
    fs::read(path).map_err(|source| {
        FolderErrorKind::Io {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })
}

#[cfg(unix)]
fn sync_dir(path: &Path) -> Result<(), FolderError> {
    fs::File::open(path)
        .and_then(|f| f.sync_all())
        .map_err(|source| {
            FolderErrorKind::Io {
                path: path.to_path_buf(),
                source,
            }
            .wrap()
        })
}

#[cfg(not(unix))]
fn sync_dir(_: &Path) -> Result<(), FolderError> {
    Ok(())
}

static TMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);

fn tmp_sibling(path: &Path) -> PathBuf {
    let file_name = path
        .file_name()
        .map(std::ffi::OsStr::to_os_string)
        .unwrap_or_default();
    let sequence = TMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
    let mut tmp_name = file_name;
    tmp_name.push(format!(".{}.{sequence}.tmp", std::process::id()));
    path.with_file_name(tmp_name)
}

pub(crate) fn read_to_string(path: &Path) -> Result<String, FolderError> {
    fs::read_to_string(path).map_err(|source| {
        FolderErrorKind::Io {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })
}

fn to_ron<T: serde::Serialize>(path: &Path, value: &T) -> Result<String, FolderError> {
    to_string(value).map_err(|source| {
        FolderErrorKind::Ron {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })
}

fn from_ron<T: serde::de::DeserializeOwned>(path: &Path, text: &str) -> Result<T, FolderError> {
    from_str(text).map_err(|source| {
        FolderErrorKind::Ron {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })
}

fn remove_stale_files(
    dir: &Path,
    live_names: &BTreeSet<String>,
    is_managed_ext: impl Fn(&str) -> bool,
) -> Result<(), FolderError> {
    let entries = match fs::read_dir(dir) {
        Ok(iter) => iter,
        Err(ref source) if source.kind() == io::ErrorKind::NotFound => return Ok(()),
        Err(source) => {
            return Err(FolderErrorKind::Io {
                path: dir.to_path_buf(),
                source,
            }
            .into());
        }
    };
    let modified = entries.into_iter().try_fold(false, |modified, entry| {
        let entry = entry.map_err(|source| {
            FolderErrorKind::Io {
                path: dir.to_path_buf(),
                source,
            }
            .wrap()
        })?;
        let name = entry.file_name().to_string_lossy().into_owned();
        let stale = Path::new(&name)
            .extension()
            .and_then(|ext| ext.to_str())
            .is_some_and(&is_managed_ext)
            && !live_names.contains(&name);
        if stale {
            let path = entry.path();
            fs::remove_file(&path).map_err(|source| FolderErrorKind::Io { path, source }.wrap())?;
            Ok::<_, FolderError>(true)
        } else {
            Ok(modified)
        }
    })?;
    if modified {
        sync_dir(dir)?;
    }
    Ok(())
}

#[cfg(test)]
mod tree_sourced_save {
    use super::{DocumentFolder, ensure_dir, load, save};
    use crate::document::{Document, DocumentHeader};
    use bone_kernel::{
        ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult,
    };
    use bone_types::{DocumentId, ExtrudeId, Length, PositiveLength, SketchId, millimeter};
    use slotmap::{Key, KeyData};
    use std::collections::BTreeMap;

    fn extrude_id(idx: u32) -> ExtrudeId {
        ExtrudeId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
    }

    fn document_id(idx: u32) -> DocumentId {
        DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
    }

    fn blind(sketch: SketchId) -> ExtrudeFeature {
        let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(10.0)) else {
            panic!("positive depth");
        };
        ExtrudeFeature {
            sketch,
            direction: ExtrudeDirection::Normal {
                sense: ExtrudeSense::Forward,
            },
            end_condition: ExtrudeEndCondition::Blind { depth },
            draft: None,
            thin_wall: None,
            merge_result: MergeResult::Merge,
        }
    }

    #[test]
    fn extrude_in_map_without_tree_node_is_not_persisted_and_stale_file_is_reaped() {
        let Ok(dir) = tempfile::tempdir() else {
            panic!("tempdir");
        };
        let folder = DocumentFolder::new(dir.path().join("orphan.bone"));

        let mut header = DocumentHeader::new(document_id(1), "orphan".to_owned());
        let orphan = extrude_id(1);
        header.extrudes.insert(orphan, blind(SketchId::null()));
        assert!(
            header.feature_tree.feature_of_extrude(orphan).is_none(),
            "the orphan starts with no feature-tree node"
        );
        let doc = Document::from_parts(header, BTreeMap::new(), BTreeMap::new());

        let Ok(()) = save(&doc, &folder) else {
            panic!("save");
        };
        assert!(
            !folder.extrude_path(orphan).exists(),
            "an extrude absent from the feature tree is not part of the document and is not written"
        );

        let Ok(()) = ensure_dir(&folder.extrudes_dir()) else {
            panic!("extrudes dir");
        };
        let Ok(()) = std::fs::write(folder.extrude_path(orphan), "ExtrudeFile()") else {
            panic!("plant stale file");
        };
        let Ok(()) = save(&doc, &folder) else {
            panic!("resave");
        };
        assert!(
            !folder.extrude_path(orphan).exists(),
            "a stale extrude file with no tree node is reaped even while the map still lists it"
        );

        let Ok(loaded) = load(&folder) else {
            panic!("load");
        };
        assert!(!loaded.header().extrudes.contains_key(&orphan));
    }
}
