//! Project export / import (Iteration 5, ADR-0015 §11 +
//! ADR-0007).
//!
//! Export produces a zip containing `<projectname>/project.yaml`
//! and `<projectname>/data/...`, with the project's directory
//! name preserved as the top-level folder inside the archive so
//! unzipping recreates a self-contained directory rather than
//! scattering files into the recipient's CWD. The derived
//! `playground.db` and the user's `history.log` are excluded
//! per ADR-0007 amendment 1.
//!
//! Import is the inverse: open a zip, validate it has a single
//! top-level folder containing a `project.yaml`, extract under
//! the chosen target. The target name is taken from the zip's
//! top-level folder (so an export-then-import round trip
//! preserves the original name even if the zip filename was
//! changed in transit). Collision on the destination
//! auto-suffixes `-02`, `-03`, ... rather than refusing — see
//! ADR-0015 §11 amendment.
//!
//! Path-traversal protection is layered: each entry's name is
//! validated via `enclosed_name()` (rejects `..` segments and
//! absolute paths), and the resolved extraction path must
//! stay under the chosen target.

use std::fs;
use std::io::{self, Read as _, Write as _};
use std::path::{Component, Path, PathBuf};

use tracing::{debug, info};
use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};

use crate::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local};

/// File names excluded from `export` zips. These are either
/// derived (`playground.db`), per-process (`.lock`),
/// per-user-private (`history.log`), or recovery artifacts
/// (`.bak` files) — none of them belong in something the user
/// shares with strangers.
const EXPORT_EXCLUDED_NAMES: &[&str] = &[
    PLAYGROUND_DB,
    HISTORY_LOG,
    ".rdbms-playground.lock",
    // Undo snapshot ring (ADR-0006 Amendment 1): local working
    // state, never shared.
    crate::undo::SNAPSHOTS_DIR,
];

/// Maximum auto-suffix attempts when resolving a colliding
/// import target. After this many tries we give up and surface
/// an error — the user can pick a different target with
/// `import <zip> as <target>` instead.
const IMPORT_SUFFIX_LIMIT: u32 = 99;

/// Maximum same-day export sequence number. The two-digit
/// pattern in the default filename caps at 99; if a single
/// day's exports exhaust the range, the user supplies an
/// explicit filename.
const EXPORT_SEQUENCE_LIMIT: u32 = 99;

#[derive(Debug)]
pub enum ArchiveError {
    Io {
        path: PathBuf,
        source: io::Error,
    },
    Zip {
        path: PathBuf,
        message: String,
    },
    ExportSequenceExhausted {
        project: String,
        target_dir: PathBuf,
        limit: u32,
    },
    ImportCollisionExhausted {
        path: PathBuf,
        limit: u32,
    },
    InvalidZip(String),
    NotAProjectArchive,
    MultipleTopFolders,
    UnsafeEntry(String),
}

impl std::fmt::Display for ArchiveError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io { path, source } => f.write_str(&crate::t!(
                "archive.io",
                path = path.display(),
                source = source,
            )),
            Self::Zip { path, message } => f.write_str(&crate::t!(
                "archive.zip",
                path = path.display(),
                message = message,
            )),
            Self::ExportSequenceExhausted {
                project,
                target_dir,
                limit,
            } => f.write_str(&crate::t!(
                "archive.export_sequence_exhausted",
                project = project,
                target_dir = target_dir.display(),
                limit = limit,
            )),
            Self::ImportCollisionExhausted { path, limit } => {
                // {limit:02} is a format specifier — the catalog
                // helper rejects those (ADR-0019 §8.4). Pre-format
                // the limit before substitution.
                f.write_str(&crate::t!(
                    "archive.import_collision_exhausted",
                    path = path.display(),
                    limit = format_args!("{limit:02}"),
                ))
            }
            Self::InvalidZip(detail) => {
                f.write_str(&crate::t!("archive.invalid_zip", detail = detail,))
            }
            Self::NotAProjectArchive => f.write_str(&crate::t!("archive.not_a_project_archive")),
            Self::MultipleTopFolders => f.write_str(&crate::t!("archive.multiple_top_folders")),
            Self::UnsafeEntry(entry) => {
                f.write_str(&crate::t!("archive.unsafe_entry", entry = entry,))
            }
        }
    }
}

impl std::error::Error for ArchiveError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Io { source, .. } => Some(source),
            _ => None,
        }
    }
}

/// Default export-zip filename for `<date>-<project>-export-NN.zip`.
///
/// `date_yyyymmdd` is the today-local prefix (8 digits);
/// `project_name` is the project's directory name (we use the
/// directory name rather than the prettified display name so
/// the export filename round-trips cleanly through `import`).
#[must_use]
pub fn default_export_filename(date_yyyymmdd: &str, project_name: &str, sequence: u32) -> String {
    format!("{date_yyyymmdd}-{project_name}-export-{sequence:02}.zip")
}

/// Find the next available `-NN` suffix for the default export
/// filename in `target_dir`, starting from 1. Returns
/// `ExportSequenceExhausted` if 1..=99 are all taken.
pub fn next_export_sequence(
    target_dir: &Path,
    project_name: &str,
) -> Result<(String, u32), ArchiveError> {
    let date = today_local();
    for seq in 1..=EXPORT_SEQUENCE_LIMIT {
        let candidate = default_export_filename(&date, project_name, seq);
        let candidate_path = target_dir.join(&candidate);
        if !candidate_path.exists() {
            return Ok((candidate, seq));
        }
    }
    Err(ArchiveError::ExportSequenceExhausted {
        project: project_name.to_string(),
        target_dir: target_dir.to_path_buf(),
        limit: EXPORT_SEQUENCE_LIMIT,
    })
}

/// Export the project at `project_path` to `dst_zip`.
///
/// `project_name` is the basename used as the top-level folder
/// inside the archive (so unzipping creates
/// `<project_name>/...`). `dst_zip` is the full path where the
/// archive will be written; the caller is responsible for
/// resolving relative paths and picking a non-clobbering
/// filename.
pub fn export_project(
    project_path: &Path,
    project_name: &str,
    dst_zip: &Path,
) -> Result<(), ArchiveError> {
    info!(
        src = %project_path.display(),
        dst = %dst_zip.display(),
        project = %project_name,
        "exporting project",
    );
    if let Some(parent) = dst_zip.parent()
        && !parent.as_os_str().is_empty()
    {
        fs::create_dir_all(parent).map_err(|source| ArchiveError::Io {
            path: parent.to_path_buf(),
            source,
        })?;
    }

    let file = fs::File::create(dst_zip).map_err(|source| ArchiveError::Io {
        path: dst_zip.to_path_buf(),
        source,
    })?;
    let mut writer = ZipWriter::new(file);
    let options: SimpleFileOptions = SimpleFileOptions::default()
        .compression_method(CompressionMethod::Deflated)
        .unix_permissions(0o644);

    add_directory_entry(&mut writer, project_name, dst_zip)?;
    add_directory_recursive(&mut writer, project_path, project_name, &options, dst_zip)?;

    writer.finish().map_err(|e| ArchiveError::Zip {
        path: dst_zip.to_path_buf(),
        message: e.to_string(),
    })?;
    Ok(())
}

fn add_directory_entry(
    writer: &mut ZipWriter<fs::File>,
    name: &str,
    zip_path: &Path,
) -> Result<(), ArchiveError> {
    let entry_name = if name.ends_with('/') {
        name.to_string()
    } else {
        format!("{name}/")
    };
    let options = SimpleFileOptions::default().unix_permissions(0o755);
    writer
        .add_directory(entry_name, options)
        .map_err(|e| ArchiveError::Zip {
            path: zip_path.to_path_buf(),
            message: e.to_string(),
        })
}

fn add_directory_recursive(
    writer: &mut ZipWriter<fs::File>,
    src_dir: &Path,
    zip_prefix: &str,
    options: &SimpleFileOptions,
    zip_path: &Path,
) -> Result<(), ArchiveError> {
    let entries = fs::read_dir(src_dir).map_err(|source| ArchiveError::Io {
        path: src_dir.to_path_buf(),
        source,
    })?;
    for entry in entries {
        let entry = entry.map_err(|source| ArchiveError::Io {
            path: src_dir.to_path_buf(),
            source,
        })?;
        let path = entry.path();
        let name_os = entry.file_name();
        let name = name_os.to_string_lossy().into_owned();
        if should_exclude_from_export(&name) {
            debug!(name = %name, "excluding from export");
            continue;
        }
        let file_type = entry.file_type().map_err(|source| ArchiveError::Io {
            path: path.clone(),
            source,
        })?;
        let entry_zip_path = format!("{zip_prefix}/{name}");
        if file_type.is_dir() {
            add_directory_entry(writer, &entry_zip_path, zip_path)?;
            add_directory_recursive(writer, &path, &entry_zip_path, options, zip_path)?;
        } else if file_type.is_file() {
            writer
                .start_file(&entry_zip_path, *options)
                .map_err(|e| ArchiveError::Zip {
                    path: zip_path.to_path_buf(),
                    message: e.to_string(),
                })?;
            let mut f = fs::File::open(&path).map_err(|source| ArchiveError::Io {
                path: path.clone(),
                source,
            })?;
            io::copy(&mut f, writer).map_err(|source| ArchiveError::Io {
                path: path.clone(),
                source,
            })?;
        }
        // Skip symlinks etc. — the project skeleton never
        // creates them, and silently dropping them in an
        // export is safer than following them blindly.
    }
    Ok(())
}

/// Per-name exclusion rules for export. We exclude:
///
/// - `playground.db` (always derived; ADR-0007 / ADR-0015 §11)
/// - `history.log` (ADR-0007 amendment 1: user-private)
/// - `.rdbms-playground.lock` (per-process)
/// - `.snapshots/` (undo ring; local working state, ADR-0006 Am. 1)
/// - `*.tmp` (atomic-write staging files)
/// - `project.yaml.v*.bak` (migration backups; recipient
///   doesn't need our local recovery aids)
fn should_exclude_from_export(name: &str) -> bool {
    if EXPORT_EXCLUDED_NAMES.contains(&name) {
        return true;
    }
    if name.ends_with(".tmp") {
        return true;
    }
    if name.starts_with("project.yaml.v") && name.ends_with(".bak") {
        return true;
    }
    false
}

/// Inspect `zip_path` and return the single top-level folder
/// name inside it, plus a `Vec` of every entry path (used by
/// the import step to extract).
///
/// Refuses if the zip contains zero top-level folders, more
/// than one, or no `project.yaml` directly under the single
/// top-level folder. This is the load-bearing check that
/// distinguishes "an export from this app" from "any old zip
/// the user happened to have."
pub fn inspect_zip(zip_path: &Path) -> Result<ZipInspection, ArchiveError> {
    let file = fs::File::open(zip_path).map_err(|source| ArchiveError::Io {
        path: zip_path.to_path_buf(),
        source,
    })?;
    let mut archive = ZipArchive::new(file).map_err(|e| ArchiveError::Zip {
        path: zip_path.to_path_buf(),
        message: e.to_string(),
    })?;

    let mut top_folder: Option<String> = None;
    let mut saw_project_yaml = false;
    for i in 0..archive.len() {
        let entry = archive.by_index(i).map_err(|e| ArchiveError::Zip {
            path: zip_path.to_path_buf(),
            message: e.to_string(),
        })?;
        let Some(safe_path) = entry.enclosed_name() else {
            return Err(ArchiveError::UnsafeEntry(entry.name().to_string()));
        };
        let mut comps = safe_path.components();
        let Some(Component::Normal(first)) = comps.next() else {
            // Empty or odd path — ignore.
            continue;
        };
        let first_str = first.to_string_lossy().into_owned();
        match &top_folder {
            None => top_folder = Some(first_str.clone()),
            Some(existing) if *existing == first_str => {}
            Some(_) => return Err(ArchiveError::MultipleTopFolders),
        }
        let rest: PathBuf = comps.collect();
        if rest == Path::new(PROJECT_YAML) {
            saw_project_yaml = true;
        }
    }

    let top_folder = top_folder.ok_or(ArchiveError::NotAProjectArchive)?;
    if !saw_project_yaml {
        return Err(ArchiveError::NotAProjectArchive);
    }
    Ok(ZipInspection { top_folder })
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ZipInspection {
    /// The single top-level folder name inside the zip. Used
    /// as the default target project name for import.
    pub top_folder: String,
}

/// Resolve a colliding target directory by appending `-NN`
/// until a free slot is found. `parent` is the directory the
/// project will live in; `name` is the desired basename.
///
/// Returns the resolved target path and the suffix that was
/// applied (0 if the original name was free, 2..=99 otherwise).
pub fn resolve_import_target(parent: &Path, name: &str) -> Result<(PathBuf, u32), ArchiveError> {
    let direct = parent.join(name);
    if !direct.exists() {
        return Ok((direct, 0));
    }
    for n in 2..=IMPORT_SUFFIX_LIMIT {
        let candidate = parent.join(format!("{name}-{n:02}"));
        if !candidate.exists() {
            return Ok((candidate, n));
        }
    }
    Err(ArchiveError::ImportCollisionExhausted {
        path: direct,
        limit: IMPORT_SUFFIX_LIMIT,
    })
}

/// Extract `zip_path` into `target_dir` (which must not yet
/// exist). The zip's single top-level folder is unwrapped: the
/// archive's `<top_folder>/foo/bar` lands at
/// `target_dir/foo/bar`.
///
/// `inspect_zip` should have been called first to validate the
/// shape; this function trusts that result and focuses on the
/// extraction. Each entry is checked for path traversal one
/// more time before being written.
pub fn extract_into(
    zip_path: &Path,
    target_dir: &Path,
    expected_top_folder: &str,
) -> Result<(), ArchiveError> {
    if target_dir.exists() {
        return Err(ArchiveError::Io {
            path: target_dir.to_path_buf(),
            source: io::Error::new(
                io::ErrorKind::AlreadyExists,
                "target directory already exists",
            ),
        });
    }
    fs::create_dir_all(target_dir).map_err(|source| ArchiveError::Io {
        path: target_dir.to_path_buf(),
        source,
    })?;

    let file = fs::File::open(zip_path).map_err(|source| ArchiveError::Io {
        path: zip_path.to_path_buf(),
        source,
    })?;
    let mut archive = ZipArchive::new(file).map_err(|e| ArchiveError::Zip {
        path: zip_path.to_path_buf(),
        message: e.to_string(),
    })?;

    for i in 0..archive.len() {
        let mut entry = archive.by_index(i).map_err(|e| ArchiveError::Zip {
            path: zip_path.to_path_buf(),
            message: e.to_string(),
        })?;
        let Some(safe_path) = entry.enclosed_name() else {
            return Err(ArchiveError::UnsafeEntry(entry.name().to_string()));
        };
        let mut comps = safe_path.components();
        let Some(Component::Normal(first)) = comps.next() else {
            continue;
        };
        if first.to_string_lossy() != expected_top_folder {
            return Err(ArchiveError::MultipleTopFolders);
        }
        let rest: PathBuf = comps.collect();
        let dst_path = if rest.as_os_str().is_empty() {
            // The top-folder entry itself (a directory). Skip
            // — `target_dir` already exists.
            continue;
        } else {
            target_dir.join(&rest)
        };
        // Defence-in-depth: confirm the resolved path stays
        // under target_dir even after components collected.
        if !dst_path.starts_with(target_dir) {
            return Err(ArchiveError::UnsafeEntry(entry.name().to_string()));
        }

        if entry.is_dir() {
            fs::create_dir_all(&dst_path).map_err(|source| ArchiveError::Io {
                path: dst_path.clone(),
                source,
            })?;
        } else {
            if let Some(parent) = dst_path.parent() {
                fs::create_dir_all(parent).map_err(|source| ArchiveError::Io {
                    path: parent.to_path_buf(),
                    source,
                })?;
            }
            let mut out = fs::File::create(&dst_path).map_err(|source| ArchiveError::Io {
                path: dst_path.clone(),
                source,
            })?;
            let mut buf = Vec::with_capacity(entry.size() as usize);
            entry
                .read_to_end(&mut buf)
                .map_err(|source| ArchiveError::Io {
                    path: dst_path.clone(),
                    source,
                })?;
            out.write_all(&buf).map_err(|source| ArchiveError::Io {
                path: dst_path.clone(),
                source,
            })?;
        }
    }
    Ok(())
}

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

    fn tempdir() -> tempfile::TempDir {
        tempfile::tempdir().expect("create tempdir")
    }

    fn make_project(root: &Path, name: &str) -> PathBuf {
        let p = root.join(name);
        fs::create_dir_all(&p).unwrap();
        fs::write(p.join(PROJECT_YAML), "version: 1\nproject:\n  created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n").unwrap();
        fs::create_dir_all(p.join("data")).unwrap();
        fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
        fs::write(
            p.join(HISTORY_LOG),
            "T|ok|create table Customers with pk id(serial)\n",
        )
        .unwrap();
        fs::write(p.join(PLAYGROUND_DB), [0u8; 32]).unwrap();
        fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap();
        // Stray atomic-write staging file — must be excluded.
        fs::write(p.join("project.yaml.tmp"), "stale").unwrap();
        // Undo snapshot ring — local working state, must be excluded.
        fs::create_dir_all(p.join(crate::undo::SNAPSHOTS_DIR).join("0")).unwrap();
        fs::write(
            p.join(crate::undo::SNAPSHOTS_DIR).join("index.yaml"),
            "next_id: 1\nundo: []\nredo: []\n",
        )
        .unwrap();
        fs::write(
            p.join(crate::undo::SNAPSHOTS_DIR)
                .join("0")
                .join(PLAYGROUND_DB),
            [0u8; 16],
        )
        .unwrap();
        p
    }

    #[test]
    fn export_excludes_db_history_lock() {
        let tmp = tempdir();
        let project = make_project(tmp.path(), "MyProject");
        let zip_path = tmp.path().join("export.zip");
        export_project(&project, "MyProject", &zip_path).unwrap();
        // Re-open and list entries.
        let f = fs::File::open(&zip_path).unwrap();
        let mut archive = ZipArchive::new(f).unwrap();
        let names: Vec<String> = (0..archive.len())
            .map(|i| archive.by_index(i).unwrap().name().to_string())
            .collect();
        assert!(
            names.iter().any(|n| n == "MyProject/project.yaml"),
            "names: {names:?}"
        );
        assert!(names.iter().any(|n| n == "MyProject/data/Customers.csv"));
        assert!(!names.iter().any(|n| n.contains("playground.db")));
        assert!(!names.iter().any(|n| n.contains("history.log")));
        assert!(!names.iter().any(|n| n.contains(".lock")));
        assert!(!names.iter().any(|n| n.ends_with(".tmp")));
        // Undo snapshot ring is local working state — never shared.
        assert!(
            !names.iter().any(|n| n.contains(".snapshots")),
            "snapshots leaked into export: {names:?}"
        );
        // .gitignore IS included (sensible default for the recipient).
        assert!(names.iter().any(|n| n == "MyProject/.gitignore"));
    }

    #[test]
    fn export_top_level_folder_is_project_name() {
        let tmp = tempdir();
        let project = make_project(tmp.path(), "MyProject");
        let zip_path = tmp.path().join("export.zip");
        export_project(&project, "MyProject", &zip_path).unwrap();
        let inspect = inspect_zip(&zip_path).unwrap();
        assert_eq!(inspect.top_folder, "MyProject");
    }

    #[test]
    fn export_carries_the_stored_input_mode() {
        // ADR-0015 mode-restore amendment (issue #14): the input
        // mode is part of project.yaml, so it travels in the export
        // verbatim — this is the teacher-prepares-an-advanced-mode
        // project, hands it to students workflow.
        use std::io::Read as _;
        let tmp = tempdir();
        let project = make_project(tmp.path(), "Advanced");
        // Re-write project.yaml with an explicit advanced mode.
        fs::write(
            project.join(PROJECT_YAML),
            "version: 1\nproject:\n  created_at: 2026-01-01T00:00:00Z\n  mode: advanced\ntables: []\nrelationships: []\n",
        )
        .unwrap();
        let zip_path = tmp.path().join("export.zip");
        export_project(&project, "Advanced", &zip_path).unwrap();

        let f = fs::File::open(&zip_path).unwrap();
        let mut archive = ZipArchive::new(f).unwrap();
        let mut entry = archive.by_name("Advanced/project.yaml").unwrap();
        let mut body = String::new();
        entry.read_to_string(&mut body).unwrap();
        assert!(
            body.contains("mode: advanced"),
            "exported project.yaml must carry the stored mode: {body}"
        );
    }

    #[test]
    fn inspect_rejects_zip_without_project_yaml() {
        let tmp = tempdir();
        let zip_path = tmp.path().join("notaproject.zip");
        let f = fs::File::create(&zip_path).unwrap();
        let mut w = ZipWriter::new(f);
        w.start_file("foo/bar.txt", SimpleFileOptions::default())
            .unwrap();
        w.write_all(b"hi").unwrap();
        w.finish().unwrap();
        let err = inspect_zip(&zip_path).expect_err("must refuse");
        assert!(
            matches!(err, ArchiveError::NotAProjectArchive),
            "got: {err:?}"
        );
    }

    #[test]
    fn inspect_rejects_zip_with_multiple_top_folders() {
        let tmp = tempdir();
        let zip_path = tmp.path().join("multi.zip");
        let f = fs::File::create(&zip_path).unwrap();
        let mut w = ZipWriter::new(f);
        w.start_file("a/project.yaml", SimpleFileOptions::default())
            .unwrap();
        w.write_all(b"x").unwrap();
        w.start_file("b/project.yaml", SimpleFileOptions::default())
            .unwrap();
        w.write_all(b"x").unwrap();
        w.finish().unwrap();
        let err = inspect_zip(&zip_path).expect_err("must refuse");
        assert!(
            matches!(err, ArchiveError::MultipleTopFolders),
            "got: {err:?}"
        );
    }

    #[test]
    fn extract_into_unwraps_top_folder() {
        let tmp = tempdir();
        let project = make_project(tmp.path(), "Source");
        let zip_path = tmp.path().join("source.zip");
        export_project(&project, "Source", &zip_path).unwrap();

        let target = tmp.path().join("imported");
        extract_into(&zip_path, &target, "Source").unwrap();
        assert!(target.join(PROJECT_YAML).exists());
        assert!(target.join("data").join("Customers.csv").exists());
        // history.log NOT present (it was excluded from export).
        assert!(!target.join(HISTORY_LOG).exists());
    }

    #[test]
    fn extract_into_refuses_existing_target() {
        let tmp = tempdir();
        let project = make_project(tmp.path(), "Source");
        let zip_path = tmp.path().join("source.zip");
        export_project(&project, "Source", &zip_path).unwrap();
        let target = tmp.path().join("existing");
        fs::create_dir(&target).unwrap();
        let err = extract_into(&zip_path, &target, "Source").expect_err("must refuse");
        assert!(matches!(err, ArchiveError::Io { .. }), "got: {err:?}");
    }

    #[test]
    fn resolve_import_target_uses_direct_when_free() {
        let tmp = tempdir();
        let (path, n) = resolve_import_target(tmp.path(), "Foo").unwrap();
        assert_eq!(path, tmp.path().join("Foo"));
        assert_eq!(n, 0);
    }

    #[test]
    fn resolve_import_target_appends_suffix_on_collision() {
        let tmp = tempdir();
        fs::create_dir(tmp.path().join("Foo")).unwrap();
        let (path, n) = resolve_import_target(tmp.path(), "Foo").unwrap();
        assert_eq!(path, tmp.path().join("Foo-02"));
        assert_eq!(n, 2);

        // Add Foo-02 and try again.
        fs::create_dir(&path).unwrap();
        let (path3, n3) = resolve_import_target(tmp.path(), "Foo").unwrap();
        assert_eq!(path3, tmp.path().join("Foo-03"));
        assert_eq!(n3, 3);
    }

    #[test]
    fn next_export_sequence_starts_at_one() {
        let tmp = tempdir();
        let (name, n) = next_export_sequence(tmp.path(), "MyProject").unwrap();
        assert_eq!(n, 1);
        assert!(name.contains("MyProject"));
        assert!(name.ends_with("-01.zip"));
    }

    #[test]
    fn next_export_sequence_skips_taken_slots() {
        let tmp = tempdir();
        let date = today_local();
        // Pre-create -01 and -02.
        fs::write(tmp.path().join(default_export_filename(&date, "P", 1)), "").unwrap();
        fs::write(tmp.path().join(default_export_filename(&date, "P", 2)), "").unwrap();
        let (_, n) = next_export_sequence(tmp.path(), "P").unwrap();
        assert_eq!(n, 3);
    }

    #[test]
    fn round_trip_export_then_inspect() {
        let tmp = tempdir();
        let project = make_project(tmp.path(), "Customers");
        let zip = tmp.path().join("export.zip");
        export_project(&project, "Customers", &zip).unwrap();
        let inspect = inspect_zip(&zip).unwrap();
        assert_eq!(inspect.top_folder, "Customers");
    }
}
