use std::fmt::Write as _;
use std::path::Path;

use cabin_core::PackageName;
use cabin_fs::write_atomic;
use camino::Utf8PathBuf;
use serde::Deserialize;

use crate::error::LockfileError;
use crate::model::{
    LockedPackage, LockedPatch, LockedPatchKind, LockedSource, LockedSourceLocatorKind,
    LockedSourceReplacement, Lockfile,
};
use crate::validate::validate;

/// Read and parse a `cabin.lock` from `path`.
///
/// # Errors
/// Returns [`LockfileError::Io`] when `path` cannot be read; otherwise
/// propagates any parse, conversion, or validation error from
/// [`parse_lockfile_str`].
pub fn read_lockfile(path: impl AsRef<Path>) -> Result<Lockfile, LockfileError> {
    let path = path.as_ref();
    let body = std::fs::read_to_string(path).map_err(|source| LockfileError::Io {
        path: path.to_path_buf(),
        source,
    })?;
    parse_lockfile_str(&body)
}

/// Parse a `cabin.lock` from an in-memory string.
///
/// # Errors
/// Returns [`LockfileError::Toml`] when `input` is not valid TOML or has
/// unexpected fields.  Propagates conversion errors while building the
/// model ([`LockfileError::InvalidPackageName`],
/// [`LockfileError::InvalidVersion`], [`LockfileError::UnknownSource`],
/// [`LockfileError::UnknownPatchKind`],
/// [`LockfileError::UnknownSourceLocatorKind`]) and any structural error
/// from [`validate`].
pub fn parse_lockfile_str(input: &str) -> Result<Lockfile, LockfileError> {
    let raw: RawLockfile = toml::from_str(input)?;
    let lockfile = lockfile_from_raw(raw)?;
    validate(&lockfile)?;
    Ok(lockfile)
}

/// Serialize `lockfile` and write it to `path`.  Existing contents are
/// replaced atomically: the new bytes are staged in a sibling
/// temporary file and only renamed onto `path` after a successful
/// write, so an interrupted run leaves the previous `cabin.lock`
/// intact.  The parent directory must already exist.
///
/// # Errors
/// Propagates validation and serialization errors from
/// [`render_lockfile`], and returns [`LockfileError::Io`] when the atomic
/// write to `path` fails (for example, a missing parent directory).
pub fn write_lockfile(path: impl AsRef<Path>, lockfile: &Lockfile) -> Result<(), LockfileError> {
    let path = path.as_ref();
    let body = render_lockfile(lockfile)?;
    write_atomic(path, &body).map_err(|source| LockfileError::Io {
        path: path.to_path_buf(),
        source,
    })
}

/// Render `lockfile` as a deterministic TOML string.  Pulled out so unit
/// tests can exercise the formatter without touching the filesystem.
///
/// # Errors
/// Propagates any structural error from [`validate`], and returns
/// [`LockfileError::TomlSer`] if a formatting write into the output
/// buffer fails.
pub fn render_lockfile(lockfile: &Lockfile) -> Result<String, LockfileError> {
    validate(lockfile)?;

    let mut out = String::new();
    out.push_str("# This file is automatically generated by Cabin.\n");
    out.push_str("# Do not edit it manually.\n\n");
    writeln!(out, "version = {}", lockfile.version)?;
    out.push('\n');

    render_packages(&mut out, &lockfile.packages)?;
    render_patches(&mut out, &lockfile.patches)?;
    render_source_replacements(&mut out, &lockfile.source_replacements)?;

    Ok(out)
}

fn render_packages(out: &mut String, packages: &[LockedPackage]) -> Result<(), LockfileError> {
    let mut packages: Vec<&LockedPackage> = packages.iter().collect();
    packages.sort_by(|a, b| {
        a.name
            .as_str()
            .cmp(b.name.as_str())
            .then_with(|| a.version.cmp(&b.version))
    });

    for pkg in packages {
        out.push_str("[[package]]\n");
        writeln!(out, "name = {}", quote_string(pkg.name.as_str()))?;
        writeln!(out, "version = {}", quote_string(&pkg.version.to_string()))?;
        writeln!(out, "source = {}", quote_string(pkg.source.as_str()))?;
        if let Some(checksum) = &pkg.checksum {
            writeln!(out, "checksum = {}", quote_string(checksum))?;
        }
        if !pkg.dependencies.is_empty() {
            let mut names: Vec<&str> = pkg.dependencies.iter().map(PackageName::as_str).collect();
            names.sort_unstable();
            out.push_str("dependencies = [");
            for (i, n) in names.iter().enumerate() {
                if i > 0 {
                    out.push_str(", ");
                }
                out.push_str(&quote_string(n));
            }
            out.push_str("]\n");
        }
        out.push('\n');
    }

    Ok(())
}

fn render_patches(out: &mut String, patches: &[LockedPatch]) -> Result<(), LockfileError> {
    let mut patches: Vec<&LockedPatch> = patches.iter().collect();
    patches.sort_by(|a, b| {
        a.package
            .as_str()
            .cmp(b.package.as_str())
            .then_with(|| a.version.cmp(&b.version))
    });
    for patch in patches {
        out.push_str("[[patch]]\n");
        writeln!(out, "package = {}", quote_string(patch.package.as_str()))?;
        writeln!(
            out,
            "version = {}",
            quote_string(&patch.version.to_string())
        )?;
        writeln!(out, "kind = {}", quote_string(patch.kind.as_str()))?;
        writeln!(out, "provenance = {}", quote_string(&patch.provenance))?;
        writeln!(out, "path = {}", quote_string(patch.path.as_str()))?;
        out.push('\n');
    }

    Ok(())
}

fn render_source_replacements(
    out: &mut String,
    source_replacements: &[LockedSourceReplacement],
) -> Result<(), LockfileError> {
    let mut replacements: Vec<&LockedSourceReplacement> = source_replacements.iter().collect();
    replacements.sort_by(|a, b| a.original.cmp(&b.original));
    for replacement in replacements {
        out.push_str("[[source-replacement]]\n");
        writeln!(out, "original = {}", quote_string(&replacement.original))?;
        writeln!(
            out,
            "original-kind = {}",
            quote_string(replacement.original_kind.as_str())
        )?;
        writeln!(
            out,
            "replacement = {}",
            quote_string(&replacement.replacement)
        )?;
        writeln!(
            out,
            "replacement-kind = {}",
            quote_string(replacement.replacement_kind.as_str())
        )?;
        writeln!(
            out,
            "provenance = {}",
            quote_string(&replacement.provenance)
        )?;
        out.push('\n');
    }

    Ok(())
}

/// Quote a string as a TOML basic string.  The lockfile only needs to handle
/// package names, version strings, source identifiers, and checksums -
/// all ASCII without surprises - but we still escape the characters TOML
/// treats as special.
fn quote_string(value: &str) -> String {
    let mut out = String::with_capacity(value.len() + 2);
    out.push('"');
    for c in value.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 || c == '\u{7f}' => {
                let _ = write!(out, "\\u{:04X}", c as u32);
            }
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

fn lockfile_from_raw(raw: RawLockfile) -> Result<Lockfile, LockfileError> {
    let RawLockfile {
        version,
        packages,
        patches,
        source_replacements,
    } = raw;

    let packages = packages
        .into_iter()
        .map(package_from_raw)
        .collect::<Result<Vec<_>, _>>()?;
    let patches = patches
        .into_iter()
        .map(patch_from_raw)
        .collect::<Result<Vec<_>, _>>()?;
    let source_replacements = source_replacements
        .into_iter()
        .map(source_replacement_from_raw)
        .collect::<Result<Vec<_>, _>>()?;

    Ok(Lockfile {
        version,
        packages,
        patches,
        source_replacements,
    })
}

fn patch_from_raw(raw: RawPatch) -> Result<LockedPatch, LockfileError> {
    let RawPatch {
        package,
        version,
        kind,
        provenance,
        path,
    } = raw;
    let package_name =
        PackageName::new(package.clone()).map_err(|reason| LockfileError::InvalidPackageName {
            name: package.clone(),
            reason,
        })?;
    let parsed_version =
        semver::Version::parse(&version).map_err(|source| LockfileError::InvalidVersion {
            name: package.clone(),
            value: version,
            source,
        })?;
    let kind_value = match kind.as_str() {
        "path" => LockedPatchKind::Path,
        other => {
            return Err(LockfileError::UnknownPatchKind {
                package,
                value: other.to_owned(),
            });
        }
    };
    Ok(LockedPatch {
        package: package_name,
        version: parsed_version,
        kind: kind_value,
        provenance,
        path: Utf8PathBuf::from(path),
    })
}

fn source_replacement_from_raw(
    raw: RawSourceReplacement,
) -> Result<LockedSourceReplacement, LockfileError> {
    let RawSourceReplacement {
        original,
        original_kind,
        replacement,
        replacement_kind,
        provenance,
    } = raw;
    let original_kind_value = locator_kind_from_str(&original_kind)?;
    let replacement_kind_value = locator_kind_from_str(&replacement_kind)?;
    Ok(LockedSourceReplacement {
        original,
        original_kind: original_kind_value,
        replacement,
        replacement_kind: replacement_kind_value,
        provenance,
    })
}

fn locator_kind_from_str(value: &str) -> Result<LockedSourceLocatorKind, LockfileError> {
    match value {
        "index-path" => Ok(LockedSourceLocatorKind::IndexPath),
        "index-url" => Ok(LockedSourceLocatorKind::IndexUrl),
        other => Err(LockfileError::UnknownSourceLocatorKind {
            value: other.to_owned(),
        }),
    }
}

fn package_from_raw(raw: RawPackage) -> Result<LockedPackage, LockfileError> {
    let RawPackage {
        name,
        version,
        source,
        checksum,
        dependencies,
    } = raw;

    let package_name =
        PackageName::new(name.clone()).map_err(|reason| LockfileError::InvalidPackageName {
            name: name.clone(),
            reason,
        })?;
    let parsed_version =
        semver::Version::parse(&version).map_err(|source| LockfileError::InvalidVersion {
            name: name.clone(),
            value: version,
            source,
        })?;
    let source_kind = match source.as_str() {
        "index" => LockedSource::Index,
        other => {
            return Err(LockfileError::UnknownSource {
                name,
                value: other.to_owned(),
            });
        }
    };
    let mut deps: Vec<PackageName> = Vec::with_capacity(dependencies.len());
    for d in dependencies {
        deps.push(
            PackageName::new(d.clone())
                .map_err(|reason| LockfileError::InvalidPackageName { name: d, reason })?,
        );
    }

    Ok(LockedPackage {
        name: package_name,
        version: parsed_version,
        source: source_kind,
        checksum,
        dependencies: deps,
    })
}

// ---------------------------------------------------------------------------
// raw serde-shaped types - kept private to this crate.
// ---------------------------------------------------------------------------

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawLockfile {
    version: u32,
    #[serde(default, rename = "package")]
    packages: Vec<RawPackage>,
    /// Local-policy patch entries recorded for stale-detection
    /// under `--locked`.  Default-empty so old lockfiles parse
    /// cleanly without modification.
    #[serde(default, rename = "patch")]
    patches: Vec<RawPatch>,
    /// Source-replacement entries for the same reason.  Defaults
    /// to empty.
    #[serde(default, rename = "source-replacement")]
    source_replacements: Vec<RawSourceReplacement>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPackage {
    name: String,
    version: String,
    source: String,
    #[serde(default)]
    checksum: Option<String>,
    #[serde(default)]
    dependencies: Vec<String>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPatch {
    package: String,
    version: String,
    kind: String,
    provenance: String,
    path: String,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawSourceReplacement {
    original: String,
    #[serde(rename = "original-kind")]
    original_kind: String,
    replacement: String,
    #[serde(rename = "replacement-kind")]
    replacement_kind: String,
    provenance: String,
}

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

    fn pkg(name: &str) -> PackageName {
        PackageName::new(name).unwrap()
    }

    fn ver(s: &str) -> semver::Version {
        semver::Version::parse(s).unwrap()
    }

    #[test]
    fn parses_single_package_lockfile() {
        let body = r#"
            version = 1

            [[package]]
            name = "fmt"
            version = "10.2.1"
            source = "index"
            checksum = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
        "#;
        let lock = parse_lockfile_str(body).unwrap();
        assert_eq!(lock.version, 1);
        assert_eq!(lock.packages.len(), 1);
        let p = &lock.packages[0];
        assert_eq!(p.name.as_str(), "fmt");
        assert_eq!(p.version, ver("10.2.1"));
        assert_eq!(p.source, LockedSource::Index);
        assert_eq!(
            p.checksum.as_deref(),
            Some("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
        );
        assert!(p.dependencies.is_empty());
    }

    #[test]
    fn parses_multi_package_lockfile_with_deps() {
        let body = r#"
            version = 1

            [[package]]
            name = "fmt"
            version = "10.2.1"
            source = "index"

            [[package]]
            name = "spdlog"
            version = "1.13.0"
            source = "index"
            dependencies = ["fmt"]
        "#;
        let lock = parse_lockfile_str(body).unwrap();
        assert_eq!(lock.packages.len(), 2);
        let spdlog = lock.find(&pkg("spdlog")).unwrap();
        assert_eq!(spdlog.dependencies, vec![pkg("fmt")]);
    }

    #[test]
    fn unsupported_version_errors() {
        let body = r#"
            version = 2

            [[package]]
            name = "fmt"
            version = "10.2.1"
            source = "index"
        "#;
        let err = parse_lockfile_str(body).unwrap_err();
        assert!(matches!(
            err,
            LockfileError::UnsupportedVersion { version: 2, .. }
        ));
    }

    #[test]
    fn duplicate_package_errors() {
        let body = r#"
            version = 1

            [[package]]
            name = "fmt"
            version = "10.2.1"
            source = "index"

            [[package]]
            name = "fmt"
            version = "10.1.0"
            source = "index"
        "#;
        let err = parse_lockfile_str(body).unwrap_err();
        assert!(matches!(err, LockfileError::DuplicatePackage { name } if name == "fmt"));
    }

    #[test]
    fn missing_version_errors() {
        let body = r#"
            version = 1

            [[package]]
            name = "fmt"
            source = "index"
        "#;
        let err = parse_lockfile_str(body).unwrap_err();
        assert!(matches!(err, LockfileError::Toml(_)));
    }

    #[test]
    fn invalid_semver_errors() {
        let body = r#"
            version = 1

            [[package]]
            name = "fmt"
            version = "abc"
            source = "index"
        "#;
        let err = parse_lockfile_str(body).unwrap_err();
        assert!(matches!(err, LockfileError::InvalidVersion { name, .. } if name == "fmt"));
    }

    #[test]
    fn unknown_field_errors() {
        let body = r#"
            version = 1

            [[package]]
            name = "fmt"
            version = "10.2.1"
            source = "index"
            extras = "nope"
        "#;
        let err = parse_lockfile_str(body).unwrap_err();
        assert!(matches!(err, LockfileError::Toml(_)));
    }

    #[test]
    fn unknown_source_errors() {
        let body = r#"
            version = 1

            [[package]]
            name = "fmt"
            version = "10.2.1"
            source = "git"
        "#;
        let err = parse_lockfile_str(body).unwrap_err();
        assert!(
            matches!(err, LockfileError::UnknownSource { name, value } if name == "fmt" && value == "git")
        );
    }

    fn sample_lockfile() -> Lockfile {
        Lockfile {
            version: 1,
            packages: vec![
                LockedPackage {
                    name: pkg("spdlog"),
                    version: ver("1.13.0"),
                    source: LockedSource::Index,
                    checksum: Some("sha256:zzz".into()),
                    dependencies: vec![pkg("fmt")],
                },
                LockedPackage {
                    name: pkg("fmt"),
                    version: ver("10.2.1"),
                    source: LockedSource::Index,
                    checksum: Some("sha256:xxx".into()),
                    dependencies: Vec::new(),
                },
            ],
            patches: Vec::new(),
            source_replacements: Vec::new(),
        }
    }

    #[test]
    fn render_sorts_packages_by_name() {
        let body = render_lockfile(&sample_lockfile()).unwrap();
        let fmt_pos = body.find("name = \"fmt\"").unwrap();
        let spdlog_pos = body.find("name = \"spdlog\"").unwrap();
        assert!(fmt_pos < spdlog_pos, "got: {body}");
    }

    #[test]
    fn render_is_deterministic() {
        let lock = sample_lockfile();
        let a = render_lockfile(&lock).unwrap();
        let b = render_lockfile(&lock).unwrap();
        assert_eq!(a, b);
    }

    #[test]
    fn render_includes_header_comment_and_version() {
        let body = render_lockfile(&sample_lockfile()).unwrap();
        assert!(body.starts_with("# This file is automatically generated by Cabin."));
        assert!(body.contains("version = 1"));
    }

    #[test]
    fn round_trip_parse_render_parse() {
        let lock = sample_lockfile();
        let body = render_lockfile(&lock).unwrap();
        let parsed = parse_lockfile_str(&body).unwrap();
        // Sort the original to match render's ordering before comparing.
        let mut expected = lock;
        expected
            .packages
            .sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
        assert_eq!(parsed, expected);
    }

    #[test]
    fn old_lockfile_without_patch_arrays_remains_valid() {
        // Older lockfiles only carry `version` + `[[package]]`.
        // The `[[patch]]` and `[[source-replacement]]` arrays are
        // declared with `#[serde(default)]` so those lockfiles
        // continue to parse cleanly.
        let body = r#"
            version = 1

            [[package]]
            name = "fmt"
            version = "10.2.1"
            source = "index"
        "#;
        let parsed = parse_lockfile_str(body).unwrap();
        assert!(parsed.patches.is_empty());
        assert!(parsed.source_replacements.is_empty());
    }

    #[test]
    fn render_emits_sorted_patch_array_then_source_replacement_array() {
        let lock = Lockfile {
            version: 1,
            packages: Vec::new(),
            patches: vec![
                LockedPatch {
                    package: pkg("spdlog"),
                    version: ver("1.13.0"),
                    kind: LockedPatchKind::Path,
                    provenance: "manifest".into(),
                    path: Utf8PathBuf::from("../spdlog"),
                },
                LockedPatch {
                    package: pkg("fmt"),
                    version: ver("10.2.1"),
                    kind: LockedPatchKind::Path,
                    provenance: "workspace-config".into(),
                    path: Utf8PathBuf::from("../fmt"),
                },
            ],
            source_replacements: vec![LockedSourceReplacement {
                original: "https://example.com/index".into(),
                original_kind: LockedSourceLocatorKind::IndexUrl,
                replacement: "../mirror".into(),
                replacement_kind: LockedSourceLocatorKind::IndexPath,
                provenance: "user-config".into(),
            }],
        };
        let body = render_lockfile(&lock).unwrap();
        let fmt_pos = body.find("package = \"fmt\"").unwrap();
        let spdlog_pos = body.find("package = \"spdlog\"").unwrap();
        assert!(fmt_pos < spdlog_pos, "patches must sort by package name");
        assert!(body.contains("[[source-replacement]]"));
        let parsed = parse_lockfile_str(&body).unwrap();
        // Patches survive a render-parse round trip in sorted
        // order so the comparison picks the rendered ordering.
        assert_eq!(parsed.patches.len(), 2);
        assert_eq!(parsed.patches[0].package.as_str(), "fmt");
        assert_eq!(parsed.source_replacements.len(), 1);
    }

    #[test]
    fn render_matches_golden_for_full_lockfile() {
        // Byte-level snapshot of a lockfile that exercises every
        // section: sorted packages with a checksum + a transitive
        // dep, two patches with different provenance, two
        // source-replacements with different locator kinds.
        // Locks down ordering, key spelling (`original-kind`,
        // `replacement-kind`), the trailing blank line per record,
        // and the absence of a final newline so future formatter
        // tweaks have to come with an intentional update here.
        let lock = Lockfile {
            version: 1,
            packages: vec![
                LockedPackage {
                    name: pkg("spdlog"),
                    version: ver("1.13.0"),
                    source: LockedSource::Index,
                    checksum: Some("sha256:zzz".into()),
                    dependencies: vec![pkg("fmt")],
                },
                LockedPackage {
                    name: pkg("fmt"),
                    version: ver("10.2.1"),
                    source: LockedSource::Index,
                    checksum: Some("sha256:xxx".into()),
                    dependencies: Vec::new(),
                },
            ],
            patches: vec![
                LockedPatch {
                    package: pkg("spdlog"),
                    version: ver("1.13.0"),
                    kind: LockedPatchKind::Path,
                    provenance: "manifest".into(),
                    path: Utf8PathBuf::from("../spdlog"),
                },
                LockedPatch {
                    package: pkg("fmt"),
                    version: ver("10.2.1"),
                    kind: LockedPatchKind::Path,
                    provenance: "workspace-config".into(),
                    path: Utf8PathBuf::from("../fmt"),
                },
            ],
            source_replacements: vec![
                LockedSourceReplacement {
                    original: "https://example.com/index".into(),
                    original_kind: LockedSourceLocatorKind::IndexUrl,
                    replacement: "../mirror".into(),
                    replacement_kind: LockedSourceLocatorKind::IndexPath,
                    provenance: "user-config".into(),
                },
                LockedSourceReplacement {
                    original: "../primary".into(),
                    original_kind: LockedSourceLocatorKind::IndexPath,
                    replacement: "https://example.org/mirror".into(),
                    replacement_kind: LockedSourceLocatorKind::IndexUrl,
                    provenance: "workspace-config".into(),
                },
            ],
        };
        let expected = "# This file is automatically generated by Cabin.\n\
# Do not edit it manually.\n\
\n\
version = 1\n\
\n\
[[package]]\n\
name = \"fmt\"\n\
version = \"10.2.1\"\n\
source = \"index\"\n\
checksum = \"sha256:xxx\"\n\
\n\
[[package]]\n\
name = \"spdlog\"\n\
version = \"1.13.0\"\n\
source = \"index\"\n\
checksum = \"sha256:zzz\"\n\
dependencies = [\"fmt\"]\n\
\n\
[[patch]]\n\
package = \"fmt\"\n\
version = \"10.2.1\"\n\
kind = \"path\"\n\
provenance = \"workspace-config\"\n\
path = \"../fmt\"\n\
\n\
[[patch]]\n\
package = \"spdlog\"\n\
version = \"1.13.0\"\n\
kind = \"path\"\n\
provenance = \"manifest\"\n\
path = \"../spdlog\"\n\
\n\
[[source-replacement]]\n\
original = \"../primary\"\n\
original-kind = \"index-path\"\n\
replacement = \"https://example.org/mirror\"\n\
replacement-kind = \"index-url\"\n\
provenance = \"workspace-config\"\n\
\n\
[[source-replacement]]\n\
original = \"https://example.com/index\"\n\
original-kind = \"index-url\"\n\
replacement = \"../mirror\"\n\
replacement-kind = \"index-path\"\n\
provenance = \"user-config\"\n\
\n";
        let body = render_lockfile(&lock).unwrap();
        assert_eq!(body, expected);
    }

    /// The lockfile is version pins only: package identity, source,
    /// checksum, dependency names, and the recorded patch /
    /// source-replacement policy.  No language standards, no
    /// toolchain information, no fingerprints - resolution inputs
    /// are manifest-declared values only, so one lockfile stays
    /// shareable across platforms and toolchains.  Parsing the
    /// rendered output generically and whitelisting every key makes
    /// any new field an explicit, reviewed decision here.
    #[test]
    fn render_writes_version_pins_only() {
        fn collect_keys(
            prefix: &str,
            value: &toml::Value,
            keys: &mut std::collections::BTreeSet<String>,
        ) {
            match value {
                toml::Value::Table(table) => {
                    for (key, nested) in table {
                        let path = if prefix.is_empty() {
                            key.clone()
                        } else {
                            format!("{prefix}.{key}")
                        };
                        keys.insert(path.clone());
                        collect_keys(&path, nested, keys);
                    }
                }
                toml::Value::Array(items) => {
                    for item in items {
                        collect_keys(prefix, item, keys);
                    }
                }
                _ => {}
            }
        }

        // Every optional section populated, so every key the writer
        // can ever emit is on the table.
        let lock = Lockfile {
            version: 1,
            packages: vec![LockedPackage {
                name: pkg("spdlog"),
                version: ver("1.13.0"),
                source: LockedSource::Index,
                checksum: Some("sha256:zzz".into()),
                dependencies: vec![pkg("fmt")],
            }],
            patches: vec![LockedPatch {
                package: pkg("fmt"),
                version: ver("10.2.1"),
                kind: LockedPatchKind::Path,
                provenance: "manifest".into(),
                path: Utf8PathBuf::from("../fmt"),
            }],
            source_replacements: vec![LockedSourceReplacement {
                original: "https://example.com/index".into(),
                original_kind: LockedSourceLocatorKind::IndexUrl,
                replacement: "../mirror".into(),
                replacement_kind: LockedSourceLocatorKind::IndexPath,
                provenance: "user-config".into(),
            }],
        };
        let body = render_lockfile(&lock).unwrap();
        let value: toml::Value = toml::from_str(&body).unwrap();
        let mut keys = std::collections::BTreeSet::new();
        collect_keys("", &value, &mut keys);
        let allowed: std::collections::BTreeSet<&str> = [
            "version",
            "package",
            "package.name",
            "package.version",
            "package.source",
            "package.checksum",
            "package.dependencies",
            "patch",
            "patch.package",
            "patch.version",
            "patch.kind",
            "patch.provenance",
            "patch.path",
            "source-replacement",
            "source-replacement.original",
            "source-replacement.original-kind",
            "source-replacement.replacement",
            "source-replacement.replacement-kind",
            "source-replacement.provenance",
        ]
        .into();
        for key in &keys {
            assert!(
                allowed.contains(key.as_str()),
                "unexpected lockfile key `{key}` - the lockfile records version pins only, \
                 never standards, toolchains, or fingerprints"
            );
        }
    }

    #[test]
    fn unknown_patch_kind_in_lockfile_yields_clear_error() {
        let body = r#"
            version = 1

            [[patch]]
            package = "fmt"
            version = "10.2.1"
            kind = "git"
            provenance = "manifest"
            path = "../fmt"
        "#;
        let err = parse_lockfile_str(body).unwrap_err();
        match err {
            LockfileError::UnknownPatchKind { value, .. } => assert_eq!(value, "git"),
            other => panic!("expected UnknownPatchKind, got {other:?}"),
        }
    }

    fn deterministic_sample() -> Lockfile {
        Lockfile {
            version: 1,
            packages: vec![LockedPackage {
                name: pkg("fmt"),
                version: ver("10.2.1"),
                source: LockedSource::Index,
                checksum: Some("sha256:xxx".into()),
                dependencies: Vec::new(),
            }],
            patches: Vec::new(),
            source_replacements: Vec::new(),
        }
    }

    #[test]
    fn write_lockfile_creates_file_with_rendered_body() {
        let dir = assert_fs::TempDir::new().unwrap();
        let path = dir.path().join("cabin.lock");
        let lock = deterministic_sample();
        write_lockfile(&path, &lock).unwrap();
        let body = std::fs::read_to_string(&path).unwrap();
        assert_eq!(body, render_lockfile(&lock).unwrap());
    }

    #[test]
    fn write_lockfile_replaces_existing_contents() {
        let dir = assert_fs::TempDir::new().unwrap();
        let path = dir.path().join("cabin.lock");
        std::fs::write(&path, "stale\n").unwrap();
        let lock = deterministic_sample();
        write_lockfile(&path, &lock).unwrap();
        let body = std::fs::read_to_string(&path).unwrap();
        assert_eq!(body, render_lockfile(&lock).unwrap());
    }

    #[test]
    fn write_lockfile_reports_destination_when_parent_directory_missing() {
        let dir = assert_fs::TempDir::new().unwrap();
        let missing_parent = dir.path().join("nonexistent").join("cabin.lock");
        let lock = deterministic_sample();
        let err = write_lockfile(&missing_parent, &lock).unwrap_err();
        match err {
            LockfileError::Io { path, .. } => assert_eq!(path, missing_parent),
            other => panic!("expected LockfileError::Io, got {other:?}"),
        }
    }

    #[test]
    fn unknown_source_locator_kind_in_lockfile_yields_clear_error() {
        let body = r#"
            version = 1

            [[source-replacement]]
            original = "https://example.com/index"
            original-kind = "index-url"
            replacement = "../mirror"
            replacement-kind = "git"
            provenance = "user-config"
        "#;
        let err = parse_lockfile_str(body).unwrap_err();
        match err {
            LockfileError::UnknownSourceLocatorKind { value } => assert_eq!(value, "git"),
            other => panic!("expected UnknownSourceLocatorKind, got {other:?}"),
        }
    }
}
