use bazaar::RevisionId;
use chrono::NaiveDateTime;
use pyo3::class::basic::CompareOp;
use pyo3::exceptions::{PyNotImplementedError, PyRuntimeError, PyTypeError, PyValueError};
use pyo3::import_exception;
use pyo3::prelude::*;
use pyo3::types::{PyBytes, PyString};
use pyo3_filelike::PyBinaryFile;
use std::collections::HashMap;
use std::io::Write;

mod annotate;
mod bisect_multi;
mod btree_index;
mod btree_serializer;
mod chk_map;
mod chunk_writer;
mod controldir;
mod dirstate;
mod dirstate_helpers;
mod errors;
mod groupcompress;
mod groupcompress_delta;
mod index;
mod inventory;
mod knit;
mod lock;
mod lru_cache;
mod multiparent;
mod osutils;
mod pack;
mod pack_repo;
mod plan_merge;
mod recordcounter;
mod smart;
mod testament;
mod textinv;
mod textmerge;
mod transport;
mod tuned_gzip;
mod versionedfile;
mod weave;
mod weavefile;

import_exception!(bzrformats._bzr_rs.errors, ReservedId);
import_exception!(breezy.bugtracker, InvalidLineInBugsProperty);
import_exception!(breezy.bugtracker, InvalidBugStatus);

/// Create a new file id suffix that is reasonably unique.
///
/// On the first call we combine the current time with 64 bits of randomness to
/// give a highly probably globally unique number. Then each call in the same
/// process adds 1 to a serial number we append to that unique value.
#[pyfunction]
#[pyo3(signature = (suffix = None))]
fn _next_id_suffix<'py>(py: Python<'py>, suffix: Option<&str>) -> Bound<'py, PyBytes> {
    PyBytes::new(py, bazaar::gen_ids::next_id_suffix(suffix).as_slice())
}

/// Return new file id for the basename 'name'.
///
/// The uniqueness is supplied from _next_id_suffix.
#[pyfunction]
fn gen_file_id(name: &str) -> bazaar::FileId {
    bazaar::FileId::generate(name)
}

/// Return a new tree-root file id.
#[pyfunction]
fn gen_root_id() -> bazaar::FileId {
    bazaar::FileId::generate_root_id()
}

/// Return new revision-id.
///
/// Args:
///   username: The username of the committer, in the format returned by
///      config.username().  This is typically a real name, followed by an
///      email address. If found, we will use just the email address portion.
///      Otherwise we flatten the real name, and use that.
/// Returns: A new revision id.
#[pyfunction]
#[pyo3(signature = (username, timestamp = None))]
fn gen_revision_id(
    py: Python,
    username: &str,
    timestamp: Option<Py<PyAny>>,
) -> PyResult<bazaar::RevisionId> {
    let timestamp = match timestamp {
        Some(timestamp) => {
            if let Ok(timestamp) = timestamp.extract::<f64>(py) {
                Some(timestamp as u64)
            } else if let Ok(timestamp) = timestamp.extract::<u64>(py) {
                Some(timestamp)
            } else {
                return Err(PyTypeError::new_err(
                    "timestamp must be a float or an int".to_string(),
                ));
            }
        }
        None => None,
    };
    Ok(bazaar::RevisionId::generate(username, timestamp))
}

#[pyfunction]
fn normalize_pattern(pattern: &str) -> String {
    bazaar::globbing::normalize_pattern(pattern)
}

#[pyclass]
struct Replacer {
    replacer: bazaar::globbing::Replacer,
}

#[pymethods]
impl Replacer {
    #[new]
    #[pyo3(signature = (source = None))]
    fn new(source: Option<&Self>) -> Self {
        Self {
            replacer: bazaar::globbing::Replacer::new(source.map(|p| &p.replacer)),
        }
    }

    /// Add a pattern and replacement.
    ///
    /// The pattern must not contain capturing groups.
    /// The replacement might be either a string template in which \& will be
    /// replaced with the match, or a function that will get the matching text
    /// as argument. It does not get match object, because capturing is
    /// forbidden anyway.
    fn add(&mut self, py: Python, pattern: &str, func: Py<PyAny>) -> PyResult<()> {
        if let Ok(func) = func.extract::<String>(py) {
            self.replacer
                .add(pattern, bazaar::globbing::Replacement::String(func));
            Ok(())
        } else {
            let callable = Box::new(move |t: String| -> String {
                Python::attach(|py| match func.call1(py, (t,)) {
                    Ok(result) => result.extract::<String>(py).unwrap(),
                    Err(e) => {
                        e.restore(py);
                        String::new()
                    }
                })
            });
            self.replacer
                .add(pattern, bazaar::globbing::Replacement::Closure(callable));
            Ok(())
        }
    }

    /// Add all patterns from another replacer.
    ///
    /// All patterns and replacements from replacer are appended to the ones
    /// already defined.
    fn add_replacer(&mut self, replacer: &Self) {
        self.replacer.add_replacer(&replacer.replacer)
    }

    fn __call__(&mut self, py: Python, text: &str) -> PyResult<String> {
        let ret = self
            .replacer
            .replace(text)
            .map_err(|e| PyValueError::new_err(e.to_string()))?;
        if PyErr::occurred(py) {
            Err(PyErr::fetch(py))
        } else {
            Ok(ret)
        }
    }
}

#[pyclass(subclass)]
struct Revision(bazaar::revision::Revision);

/// Single revision on a branch.
///
/// Revisions may know their revision_hash, but only once they've been
/// written out.  This is not stored because you cannot write the hash
/// into the file it describes.
///
/// Attributes:
///   parent_ids: List of parent revision_ids
///
///   properties:
///     Dictionary of revision properties.  These are attached to the
///     revision as extra metadata.  The name must be a single
///     word; the value can be an arbitrary string.
#[pymethods]
impl Revision {
    #[new]
    #[pyo3(signature = (revision_id, parent_ids, committer, message, properties, inventory_sha1, timestamp, timezone))]
    fn new(
        py: Python,
        revision_id: RevisionId,
        parent_ids: Vec<RevisionId>,
        committer: Option<String>,
        message: String,
        properties: Option<HashMap<String, Py<PyAny>>>,
        inventory_sha1: Option<Vec<u8>>,
        timestamp: f64,
        timezone: Option<i32>,
    ) -> PyResult<Self> {
        let mut cproperties: HashMap<String, Vec<u8>> = HashMap::new();
        for (k, v) in properties.unwrap_or_default() {
            if let Ok(s) = v.extract::<Bound<PyBytes>>(py) {
                cproperties.insert(k, s.as_bytes().to_vec());
            } else if let Ok(s) = v.extract::<Bound<PyString>>(py) {
                let s = s
                    .call_method1("encode", ("utf-8", "surrogateescape"))?
                    .extract::<Bound<PyBytes>>()?;
                cproperties.insert(k, s.as_bytes().to_vec());
            } else {
                return Err(PyTypeError::new_err(
                    "properties must be a dictionary of strings",
                ));
            }
        }

        if !bazaar::revision::validate_properties(&cproperties) {
            return Err(PyValueError::new_err(
                "properties must be a dictionary of strings",
            ));
        }
        Ok(Self(bazaar::revision::Revision {
            revision_id,
            parent_ids,
            committer,
            message,
            properties: cproperties,
            inventory_sha1,
            timestamp,
            timezone,
        }))
    }

    fn __richcmp__(&self, other: &Self, op: CompareOp) -> PyResult<bool> {
        match op {
            CompareOp::Eq => Ok(self.0 == other.0),
            CompareOp::Ne => Ok(self.0 != other.0),
            _ => Err(PyNotImplementedError::new_err(
                "only == and != are supported",
            )),
        }
    }

    fn __repr__(self_: PyRef<Self>) -> String {
        format!("<Revision id {:?}>", self_.0.revision_id)
    }

    #[getter]
    fn revision_id(&self) -> &bazaar::RevisionId {
        &self.0.revision_id
    }

    #[getter]
    fn parent_ids(&self) -> &Vec<RevisionId> {
        &self.0.parent_ids
    }

    #[getter]
    fn committer(&self) -> Option<String> {
        self.0.committer.clone()
    }

    #[getter]
    fn message(&self) -> String {
        self.0.message.clone()
    }

    #[getter]
    fn properties(&self) -> HashMap<String, String> {
        self.0
            .properties
            .iter()
            .map(|(k, v)| (k.clone(), String::from_utf8_lossy(v).into()))
            .collect()
    }

    #[getter]
    fn get_inventory_sha1<'py>(&self, py: Python<'py>) -> Bound<'py, PyAny> {
        if let Some(sha1) = &self.0.inventory_sha1 {
            PyBytes::new(py, sha1).into_any()
        } else {
            py.None().into_bound(py)
        }
    }

    #[setter]
    fn set_inventory_sha1(&mut self, py: Python, value: Py<PyAny>) -> PyResult<()> {
        if let Ok(value) = value.extract::<Bound<PyBytes>>(py) {
            self.0.inventory_sha1 = Some(value.as_bytes().to_vec());
            Ok(())
        } else if value.is_none(py) {
            self.0.inventory_sha1 = None;
            Ok(())
        } else {
            Err(PyTypeError::new_err("expected bytes or None"))
        }
    }

    #[getter]
    fn timestamp(&self) -> f64 {
        self.0.timestamp
    }

    #[getter]
    fn timezone(&self) -> Option<i32> {
        self.0.timezone
    }

    fn datetime(&self) -> NaiveDateTime {
        self.0.datetime()
    }

    fn check_properties(&self) -> PyResult<()> {
        if self.0.check_properties() {
            Ok(())
        } else {
            Err(PyValueError::new_err("invalid properties"))
        }
    }

    fn get_summary(&self) -> String {
        self.0.get_summary()
    }

    fn get_apparent_authors(&self) -> Vec<String> {
        self.0.get_apparent_authors()
    }

    fn bug_urls(&self) -> Vec<String> {
        self.0.bug_urls()
    }

    /// Iterate over `(url, status)` tuples decoded from the ``bugs`` property.
    ///
    /// Mirrors `breezy.revision.Revision.iter_bugs`. Malformed lines raise
    /// `breezy.bugtracker.InvalidLineInBugsProperty`; unknown status tokens
    /// raise `breezy.bugtracker.InvalidBugStatus`.
    fn iter_bugs<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
        match self.0.iter_bugs() {
            Ok(pairs) => Ok(pyo3::types::PyList::new(py, pairs)?.try_iter()?.into_any()),
            Err(bazaar::revision::BugError::InvalidLine(line)) => {
                Err(InvalidLineInBugsProperty::new_err(line))
            }
            Err(bazaar::revision::BugError::InvalidStatus(status)) => {
                Err(InvalidBugStatus::new_err(status))
            }
        }
    }
}

/// Revision plus the v4-only metadata recovered by
/// [`XMLRevisionSerializer4`]. Subclasses [`Revision`] so it exposes
/// all the usual revision attributes and is `isinstance(_, Revision)`.
#[pyclass(extends = Revision)]
struct RevisionV4 {
    inventory_id: Option<Vec<u8>>,
    parent_sha1s: Vec<Option<Vec<u8>>>,
}

#[pymethods]
impl RevisionV4 {
    #[getter]
    fn inventory_id<'py>(&self, py: Python<'py>) -> Bound<'py, PyAny> {
        match &self.inventory_id {
            Some(v) => PyBytes::new(py, v).into_any(),
            None => py.None().into_bound(py),
        }
    }

    #[getter]
    fn parent_sha1s<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
        let out = pyo3::types::PyList::empty(py);
        for entry in &self.parent_sha1s {
            match entry {
                Some(v) => out.append(PyBytes::new(py, v))?,
                None => out.append(py.None())?,
            }
        }
        Ok(out.into_any())
    }
}

fn serializer_err_to_py_err(e: bazaar::serializer::Error) -> PyErr {
    PyRuntimeError::new_err(format!("serializer error: {:?}", e))
}

#[pyclass(subclass)]
struct RevisionSerializer(Box<dyn bazaar::serializer::RevisionSerializer>);

#[pyclass(subclass,extends=RevisionSerializer)]
struct BEncodeRevisionSerializerv1;

#[pymethods]
impl BEncodeRevisionSerializerv1 {
    #[new]
    fn new() -> (Self, RevisionSerializer) {
        (
            Self {},
            RevisionSerializer(Box::new(
                bazaar::bencode_serializer::BEncodeRevisionSerializer1,
            )),
        )
    }
}

#[pyclass(subclass,extends=RevisionSerializer)]
struct XMLRevisionSerializer8;

#[pymethods]
impl XMLRevisionSerializer8 {
    #[new]
    fn new() -> (Self, RevisionSerializer) {
        (
            Self {},
            RevisionSerializer(Box::new(bazaar::xml_serializer::XMLRevisionSerializer8)),
        )
    }
}

/// v4 revision serializer (deserialization-only). Unlike v5/v8 it
/// also recovers `inventory_id` and `parent_sha1s` metadata, so it
/// doesn't fit the `RevisionSerializer` trait shape — it's exposed
/// directly with its own read methods that return a tuple.
#[pyclass]
struct XMLRevisionSerializer4;

#[pymethods]
impl XMLRevisionSerializer4 {
    #[new]
    fn new() -> Self {
        Self
    }

    #[getter]
    fn format_name(&self) -> &'static str {
        "4"
    }

    #[getter]
    fn squashes_xml_invalid_characters(&self) -> bool {
        true
    }

    fn read_revision_from_string<'py>(
        &self,
        py: Python<'py>,
        text: &[u8],
    ) -> PyResult<Bound<'py, RevisionV4>> {
        let rv4 = py
            .detach(|| {
                bazaar::xml_serializer::XMLRevisionSerializer4.read_revision_from_string(text)
            })
            .map_err(serializer_err_to_py_err)?;
        build_revision_v4(py, rv4)
    }

    fn read_revision<'py>(
        &self,
        py: Python<'py>,
        file: Py<PyAny>,
    ) -> PyResult<Bound<'py, RevisionV4>> {
        let mut file = PyBinaryFile::from(file);
        let rv4 = py
            .detach(|| bazaar::xml_serializer::XMLRevisionSerializer4.read_revision(&mut file))
            .map_err(serializer_err_to_py_err)?;
        build_revision_v4(py, rv4)
    }
}

fn build_revision_v4(
    py: Python<'_>,
    rv4: bazaar::xml_serializer::RevisionV4,
) -> PyResult<Bound<'_, RevisionV4>> {
    let init = pyo3::PyClassInitializer::from(Revision(rv4.revision)).add_subclass(RevisionV4 {
        inventory_id: rv4.inventory_id,
        parent_sha1s: rv4.parent_sha1s,
    });
    Bound::new(py, init)
}

#[pyclass(subclass,extends=RevisionSerializer)]
struct XMLRevisionSerializer5;

#[pymethods]
impl XMLRevisionSerializer5 {
    #[new]
    fn new() -> (Self, RevisionSerializer) {
        (
            Self {},
            RevisionSerializer(Box::new(bazaar::xml_serializer::XMLRevisionSerializer5)),
        )
    }
}

#[pymethods]
impl RevisionSerializer {
    #[getter]
    fn format_name(&self) -> String {
        self.0.format_name().to_string()
    }

    #[getter]
    fn squashes_xml_invalid_characters(&self) -> bool {
        self.0.squashes_xml_invalid_characters()
    }

    fn read_revision(&self, py: Python, file: Py<PyAny>) -> PyResult<Revision> {
        py.detach(|| {
            let mut file = PyBinaryFile::from(file);
            Ok(Revision(
                self.0
                    .read_revision(&mut file)
                    .map_err(serializer_err_to_py_err)?,
            ))
        })
    }

    fn write_revision_to_string<'py>(
        &self,
        py: Python<'py>,
        revision: &Revision,
    ) -> PyResult<Bound<'py, PyBytes>> {
        Ok(PyBytes::new(
            py,
            py.detach(|| self.0.write_revision_to_string(&revision.0))
                .map_err(serializer_err_to_py_err)?
                .as_slice(),
        ))
    }

    fn write_revision_to_lines<'a>(
        &self,
        py: Python<'a>,
        revision: &Revision,
    ) -> PyResult<Vec<Bound<'a, PyBytes>>> {
        self.0
            .write_revision_to_lines(&revision.0)
            .map(|s| -> PyResult<Bound<PyBytes>> {
                Ok(PyBytes::new(
                    py,
                    s.map_err(serializer_err_to_py_err)?.as_slice(),
                ))
            })
            .collect::<PyResult<Vec<Bound<PyBytes>>>>()
    }

    fn read_revision_from_string(&self, py: Python, string: &[u8]) -> PyResult<Revision> {
        Ok(Revision(
            py.detach(|| self.0.read_revision_from_string(string))
                .map_err(serializer_err_to_py_err)?,
        ))
    }
}

import_exception!(bzrformats.serializer, UnexpectedInventoryFormat);
import_exception!(bzrformats.serializer, UnsupportedInventoryKind);

fn inventory_serializer_err_to_py_err(e: bazaar::serializer::Error) -> PyErr {
    use bazaar::serializer::Error;
    match e {
        Error::UnexpectedInventoryFormat(msg) => UnexpectedInventoryFormat::new_err(msg),
        Error::UnsupportedInventoryKind(kind) => UnsupportedInventoryKind::new_err((kind,)),
        other => PyRuntimeError::new_err(format!("serializer error: {:?}", other)),
    }
}

#[pyclass(subclass)]
struct InventorySerializer(Box<dyn bazaar::serializer::InventorySerializer>);

#[pymethods]
impl InventorySerializer {
    #[getter]
    fn format_num<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> {
        PyBytes::new(py, self.0.format_num())
    }

    #[getter]
    fn support_altered_by_hack(&self) -> bool {
        self.0.support_altered_by_hack()
    }

    #[pyo3(signature = (inv, f, working = false))]
    fn write_inventory<'py>(
        &self,
        py: Python<'py>,
        inv: &inventory::Inventory,
        f: Py<PyAny>,
        working: bool,
    ) -> PyResult<Vec<Bound<'py, PyBytes>>> {
        let lines = self
            .0
            .write_inventory_to_lines(&inv.0, working)
            .map_err(inventory_serializer_err_to_py_err)?;
        if !f.is_none(py) {
            for line in &lines {
                f.call_method1(py, "write", (PyBytes::new(py, line),))?;
            }
        }
        Ok(lines.into_iter().map(|l| PyBytes::new(py, &l)).collect())
    }

    fn write_inventory_to_lines<'py>(
        &self,
        py: Python<'py>,
        inv: &inventory::Inventory,
    ) -> PyResult<Vec<Bound<'py, PyBytes>>> {
        let lines = self
            .0
            .write_inventory_to_lines(&inv.0, false)
            .map_err(inventory_serializer_err_to_py_err)?;
        Ok(lines.into_iter().map(|l| PyBytes::new(py, &l)).collect())
    }

    fn write_inventory_to_chunks<'py>(
        &self,
        py: Python<'py>,
        inv: &inventory::Inventory,
    ) -> PyResult<Vec<Bound<'py, PyBytes>>> {
        self.write_inventory_to_lines(py, inv)
    }

    fn write_inventory_to_string<'py>(
        &self,
        py: Python<'py>,
        inv: &inventory::Inventory,
    ) -> PyResult<Bound<'py, PyBytes>> {
        let buf = self
            .0
            .write_inventory_to_string(&inv.0, false)
            .map_err(inventory_serializer_err_to_py_err)?;
        Ok(PyBytes::new(py, &buf))
    }

    #[pyo3(signature = (lines, revision_id = None, entry_cache = None, return_from_cache = false))]
    fn read_inventory_from_lines(
        &self,
        py: Python,
        lines: Vec<Vec<u8>>,
        revision_id: Option<RevisionId>,
        entry_cache: Option<Py<PyAny>>,
        return_from_cache: bool,
    ) -> PyResult<inventory::Inventory> {
        let _ = (entry_cache, return_from_cache);
        let line_refs: Vec<&[u8]> = lines.iter().map(|v| v.as_slice()).collect();
        let inv = py
            .detach(|| self.0.read_inventory_from_lines(&line_refs, revision_id))
            .map_err(inventory_serializer_err_to_py_err)?;
        Ok(inventory::Inventory(inv))
    }

    #[pyo3(signature = (f, revision_id = None))]
    fn read_inventory(
        &self,
        py: Python,
        f: Py<PyAny>,
        revision_id: Option<RevisionId>,
    ) -> PyResult<inventory::Inventory> {
        let mut file = PyBinaryFile::from(f);
        let inv = py
            .detach(|| self.0.read_inventory(&mut file, revision_id))
            .map_err(inventory_serializer_err_to_py_err)?;
        Ok(inventory::Inventory(inv))
    }

    fn _find_text_key_references<'py>(
        &self,
        py: Python<'py>,
        line_iterator: Py<PyAny>,
    ) -> PyResult<Bound<'py, pyo3::types::PyDict>> {
        let iter = line_iterator.bind(py).try_iter()?;
        let mut owned: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
        for item in iter {
            let item = item?;
            let tuple = item.cast::<pyo3::types::PyTuple>().map_err(|_| {
                PyTypeError::new_err("line_iterator must yield (line, line_key) tuples")
            })?;
            if tuple.len() != 2 {
                return Err(PyTypeError::new_err(
                    "line_iterator must yield 2-tuples of (line, line_key)",
                ));
            }
            let line: Vec<u8> = tuple.get_item(0)?.extract()?;
            // Mirror upstream Python's `revision_id == line_key[-1]`: when
            // line_key is a versionedfile key tuple like (revision_id,) we
            // take the last element. Some callers (knitpack_repo's
            // find_text_keys_from_content) pass bare bytes as line_key, in
            // which case Python's `line_key[-1]` yields an int that never
            // compares equal to the unescaped revision id — so the matching
            // step always fails. We replicate that by passing an empty
            // sentinel that won't match any real revision id.
            let line_key = tuple.get_item(1)?;
            let revid: Vec<u8> = if let Ok(key_tuple) = line_key.cast::<pyo3::types::PyTuple>() {
                if key_tuple.is_empty() {
                    return Err(PyTypeError::new_err("line_key tuple must be non-empty"));
                }
                key_tuple.get_item(key_tuple.len() - 1)?.extract()?
            } else {
                Vec::new()
            };
            owned.push((line, revid));
        }
        let result = py
            .detach(|| {
                bazaar::xml_serializer::find_text_key_references(
                    owned.iter().map(|(l, k)| (l.as_slice(), k.as_slice())),
                )
            })
            .map_err(inventory_serializer_err_to_py_err)?;
        let dict = pyo3::types::PyDict::new(py);
        for ((file_id, revision_id), present) in result {
            let key = pyo3::types::PyTuple::new(
                py,
                [PyBytes::new(py, &file_id), PyBytes::new(py, &revision_id)],
            )?;
            dict.set_item(key, present)?;
        }
        Ok(dict)
    }
}

#[pyclass(subclass, extends = InventorySerializer)]
struct XMLInventorySerializer4;

#[pymethods]
impl XMLInventorySerializer4 {
    #[new]
    fn new() -> (Self, InventorySerializer) {
        (
            Self,
            InventorySerializer(Box::new(bazaar::xml_serializer::XMLInventorySerializer4)),
        )
    }
}

#[pyclass(subclass, extends = InventorySerializer)]
struct XMLInventorySerializer5;

#[pymethods]
impl XMLInventorySerializer5 {
    #[new]
    fn new() -> (Self, InventorySerializer) {
        (
            Self,
            InventorySerializer(Box::new(bazaar::xml_serializer::XMLInventorySerializer5)),
        )
    }
}

#[pyclass(subclass, extends = InventorySerializer)]
struct XMLInventorySerializer6;

#[pymethods]
impl XMLInventorySerializer6 {
    #[new]
    fn new() -> (Self, InventorySerializer) {
        (
            Self,
            InventorySerializer(Box::new(bazaar::xml_serializer::XMLInventorySerializer6)),
        )
    }
}

#[pyclass(subclass, extends = InventorySerializer)]
struct XMLInventorySerializer7;

#[pymethods]
impl XMLInventorySerializer7 {
    #[new]
    fn new() -> (Self, InventorySerializer) {
        (
            Self,
            InventorySerializer(Box::new(bazaar::xml_serializer::XMLInventorySerializer7)),
        )
    }
}

#[pyclass(subclass, extends = InventorySerializer)]
struct XMLInventorySerializer8;

#[pymethods]
impl XMLInventorySerializer8 {
    #[new]
    fn new() -> (Self, InventorySerializer) {
        (
            Self,
            InventorySerializer(Box::new(bazaar::xml_serializer::XMLInventorySerializer8)),
        )
    }
}

/// CHK-based inventory serializer. Parameterised over the wire format
/// number, max page size, and search-key name; the on-disk format is
/// otherwise the same as v8/v10 etc. Surfaces `maximum_size` and
/// `search_key_name` as Python-readable getters because the original
/// Python `CHKSerializer` exposed them as instance attributes.
#[pyclass(subclass, extends = InventorySerializer)]
struct CHKInventorySerializer {
    maximum_size: usize,
    search_key_name: Vec<u8>,
}

#[pymethods]
impl CHKInventorySerializer {
    #[new]
    fn new(
        format_num: Vec<u8>,
        maximum_size: usize,
        search_key_name: Vec<u8>,
    ) -> (Self, InventorySerializer) {
        (
            CHKInventorySerializer {
                maximum_size,
                search_key_name: search_key_name.clone(),
            },
            InventorySerializer(Box::new(bazaar::xml_serializer::CHKSerializer::new(
                format_num,
                maximum_size,
                search_key_name,
            ))),
        )
    }

    #[getter]
    fn maximum_size(&self) -> usize {
        self.maximum_size
    }

    #[getter]
    fn search_key_name<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> {
        PyBytes::new(py, &self.search_key_name)
    }

    /// Write an inventory, dispatching on its concrete type. The Rust
    /// ``Inventory`` pyclass takes the native fast path; any other (duck-typed)
    /// inventory such as the pure-Python ``CHKInventory`` is serialized by
    /// reading its entries via attribute access.
    #[pyo3(signature = (inv, f, working = false))]
    fn write_inventory<'py>(
        slf: PyRef<'py, Self>,
        py: Python<'py>,
        inv: Py<PyAny>,
        f: Py<PyAny>,
        working: bool,
    ) -> PyResult<Vec<Bound<'py, PyBytes>>> {
        let lines = chk_write_inventory_line_bytes(&slf, inv.bind(py), working)?;
        if !f.is_none(py) {
            let mut file = PyBinaryFile::from(f);
            for line in &lines {
                file.write_all(line)?;
            }
        }
        Ok(lines.into_iter().map(|l| PyBytes::new(py, &l)).collect())
    }

    fn write_inventory_to_lines<'py>(
        slf: PyRef<'py, Self>,
        py: Python<'py>,
        inv: Py<PyAny>,
    ) -> PyResult<Vec<Bound<'py, PyBytes>>> {
        let lines = chk_write_inventory_line_bytes(&slf, inv.bind(py), false)?;
        Ok(lines.into_iter().map(|l| PyBytes::new(py, &l)).collect())
    }

    fn write_inventory_to_chunks<'py>(
        slf: PyRef<'py, Self>,
        py: Python<'py>,
        inv: Py<PyAny>,
    ) -> PyResult<Vec<Bound<'py, PyBytes>>> {
        let lines = chk_write_inventory_line_bytes(&slf, inv.bind(py), false)?;
        Ok(lines.into_iter().map(|l| PyBytes::new(py, &l)).collect())
    }

    /// Serialize a duck-typed inventory (e.g. ``CHKInventory``) to a list of
    /// byte lines. The Rust ``CHKSerializer`` only accepts the Rust
    /// ``Inventory`` pyclass, but ``CHKInventory`` lives in pure Python — this
    /// shim extracts its entries (each one is a Rust-backed ``InventoryEntry``)
    /// and dispatches to the same Rust XML writer.
    #[pyo3(signature = (inv, working = false))]
    fn write_inventory_duck_to_lines<'py>(
        slf: PyRef<'py, Self>,
        py: Python<'py>,
        inv: Py<PyAny>,
        working: bool,
    ) -> PyResult<Vec<Bound<'py, PyBytes>>> {
        let lines = chk_write_inventory_duck(&slf, inv.bind(py), working)?;
        Ok(lines.into_iter().map(|l| PyBytes::new(py, &l)).collect())
    }
}

/// Serialize an inventory to byte lines, dispatching on its concrete type. A
/// Rust ``Inventory`` pyclass takes the native serializer; anything else is
/// treated as a duck-typed inventory and read via attribute access.
fn chk_write_inventory_line_bytes(
    slf: &PyRef<'_, CHKInventorySerializer>,
    inv: &Bound<'_, PyAny>,
    working: bool,
) -> PyResult<Vec<Vec<u8>>> {
    if let Ok(native) = inv.extract::<PyRef<inventory::Inventory>>() {
        return slf
            .as_super()
            .0
            .write_inventory_to_lines(&native.0, working)
            .map_err(inventory_serializer_err_to_py_err);
    }
    chk_write_inventory_duck(slf, inv, working)
}

/// Serialize a duck-typed inventory to byte lines using attribute access. The
/// header parts (format, revision_id, root) are read here; the pure-Rust
/// writer in the ``bazaar`` crate produces the byte stream.
fn chk_write_inventory_duck(
    slf: &PyRef<'_, CHKInventorySerializer>,
    inv: &Bound<'_, PyAny>,
    working: bool,
) -> PyResult<Vec<Vec<u8>>> {
    let format_num = slf.as_super().0.format_num().to_vec();

    let revision_id_attr = inv.getattr("revision_id")?;
    let revision_id: Option<Vec<u8>> = if revision_id_attr.is_none() {
        None
    } else {
        Some(revision_id_attr.extract()?)
    };

    let root = inv.getattr("root")?;
    let root_file_id: Vec<u8> = root.getattr("file_id")?.extract()?;
    let root_name: String = root.getattr("name")?.extract()?;
    let root_revision: Vec<u8> = root.getattr("revision")?.extract()?;

    let entries_iter = inv.call_method0("iter_entries")?.try_iter()?;
    // Skip the root, which iter_entries yields first.
    let mut entries: Vec<bazaar::inventory::Entry> = Vec::new();
    let mut first = true;
    for item in entries_iter {
        let pair = item?;
        if first {
            first = false;
            continue;
        }
        let ie = pair.get_item(1)?;
        entries.push(inventory_entry_from_py(&ie)?);
    }

    bazaar::xml_serializer::serialize_chk_inventory_parts(
        &format_num,
        revision_id.as_deref(),
        &root_file_id,
        &root_name,
        &root_revision,
        entries.iter(),
        working,
    )
    .map_err(inventory_serializer_err_to_py_err)
}

/// Build a Rust ``Entry`` from a duck-typed Python inventory entry. The fast
/// path (which is what ``CHKInventory`` produces) extracts the underlying
/// Rust ``Entry`` from an ``InventoryEntry`` pyclass directly; the fallback
/// reads named attributes one at a time, so any object that quacks like an
/// inventory entry also works.
fn inventory_entry_from_py(obj: &Bound<'_, PyAny>) -> PyResult<bazaar::inventory::Entry> {
    if let Ok(ie) = obj.extract::<PyRef<inventory::InventoryEntry>>() {
        return Ok(ie.0.clone());
    }
    let kind: String = obj.getattr("kind")?.extract()?;
    let file_id: Vec<u8> = obj.getattr("file_id")?.extract()?;
    let file_id = bazaar::FileId::from(file_id.as_slice());
    let name: String = obj.getattr("name")?.extract()?;
    let parent_id_attr = obj.getattr("parent_id")?;
    let parent_id: Option<bazaar::FileId> = if parent_id_attr.is_none() {
        None
    } else {
        let bytes: Vec<u8> = parent_id_attr.extract()?;
        Some(bazaar::FileId::from(bytes.as_slice()))
    };
    let revision_attr = obj.getattr("revision")?;
    let revision: Option<bazaar::RevisionId> = if revision_attr.is_none() {
        None
    } else {
        let bytes: Vec<u8> = revision_attr.extract()?;
        Some(bazaar::RevisionId::from(bytes.as_slice()))
    };
    match kind.as_str() {
        "directory" => {
            let parent_id = parent_id
                .ok_or_else(|| PyValueError::new_err("directory entry missing parent_id"))?;
            Ok(bazaar::inventory::Entry::directory(
                file_id, name, parent_id, revision,
            ))
        }
        "file" => {
            let parent_id =
                parent_id.ok_or_else(|| PyValueError::new_err("file entry missing parent_id"))?;
            let text_sha1_attr = obj.getattr("text_sha1")?;
            let text_sha1: Option<Vec<u8>> = if text_sha1_attr.is_none() {
                None
            } else {
                Some(text_sha1_attr.extract()?)
            };
            let text_size_attr = obj.getattr("text_size")?;
            let text_size: Option<u64> = if text_size_attr.is_none() {
                None
            } else {
                Some(text_size_attr.extract()?)
            };
            let executable: bool = obj.getattr("executable")?.extract().unwrap_or(false);
            Ok(bazaar::inventory::Entry::file(
                file_id,
                name,
                parent_id,
                revision,
                text_sha1,
                text_size,
                Some(executable),
                None,
            ))
        }
        "symlink" => {
            let parent_id = parent_id
                .ok_or_else(|| PyValueError::new_err("symlink entry missing parent_id"))?;
            let target_attr = obj.getattr("symlink_target")?;
            let symlink_target: Option<String> = if target_attr.is_none() {
                None
            } else {
                Some(target_attr.extract()?)
            };
            Ok(bazaar::inventory::Entry::link(
                file_id,
                name,
                parent_id,
                revision,
                symlink_target,
            ))
        }
        "tree-reference" => {
            let parent_id = parent_id
                .ok_or_else(|| PyValueError::new_err("tree-reference entry missing parent_id"))?;
            let reference_revision_attr = obj.getattr("reference_revision")?;
            let reference_revision: Option<bazaar::RevisionId> =
                if reference_revision_attr.is_none() {
                    None
                } else {
                    let bytes: Vec<u8> = reference_revision_attr.extract()?;
                    Some(bazaar::RevisionId::from(bytes.as_slice()))
                };
            Ok(bazaar::inventory::Entry::tree_reference(
                file_id,
                name,
                parent_id,
                revision,
                reference_revision,
            ))
        }
        other => Err(PyValueError::new_err(format!(
            "unsupported inventory entry kind: {}",
            other
        ))),
    }
}

#[pyfunction(name = "is_null")]
fn is_null_revision(revision_id: RevisionId) -> bool {
    revision_id.is_null()
}

#[pyfunction(name = "is_reserved_id")]
fn is_reserved_revision_id(revision_id: RevisionId) -> bool {
    revision_id.is_reserved()
}

#[pyfunction(name = "check_not_reserved_id")]
fn check_not_reserved_id(_py: Python, revision_id: Bound<PyBytes>) -> PyResult<()> {
    if revision_id.is_none() {
        return Ok(());
    }
    if let Ok(revision_id) = revision_id.extract::<RevisionId>() {
        if revision_id.is_reserved() {
            Err(ReservedId::new_err((revision_id,)))
        } else {
            Ok(())
        }
    } else {
        // For now, just ignore other types..
        Ok(())
    }
}

#[pyfunction]
#[pyo3(signature = (message = None))]
fn escape_invalid_chars(message: Option<&str>) -> (Option<String>, usize) {
    if let Some(message) = message {
        (
            Some(bazaar::xml_serializer::escape_invalid_chars(message)),
            message.len(),
        )
    } else {
        (None, 0)
    }
}

#[pyfunction]
fn encode_and_escape(py: Python, unicode_or_utf8_str: Py<PyAny>) -> PyResult<Bound<PyBytes>> {
    let ret = if let Ok(text) = unicode_or_utf8_str.extract::<String>(py) {
        bazaar::xml_serializer::encode_and_escape_string(&text)
    } else if let Ok(bytes) = unicode_or_utf8_str.extract::<Vec<u8>>(py) {
        bazaar::xml_serializer::encode_and_escape_bytes(&bytes)
    } else {
        return Err(PyTypeError::new_err("expected str or bytes"));
    };

    Ok(PyBytes::new(py, ret.as_bytes()))
}

/// Unescape predefined XML entities in a string of data. Mirrors
/// `bzrformats.xml8._unescape_xml`; an unknown entity raises `KeyError`.
#[pyfunction]
fn _unescape_xml<'py>(py: Python<'py>, data: &[u8]) -> PyResult<Bound<'py, PyBytes>> {
    match bazaar::xml_serializer::unescape_xml(data) {
        Ok(out) => Ok(PyBytes::new(py, &out)),
        // unescape_xml only ever fails with a decode error (unknown entity);
        // the Python original raised KeyError for that case.
        Err(bazaar::serializer::Error::DecodeError(msg)) => {
            Err(pyo3::exceptions::PyKeyError::new_err(msg))
        }
        Err(other) => Err(PyValueError::new_err(format!("{:?}", other))),
    }
}

mod hashcache;
mod rio;

#[pymodule]
fn _bzr_rs(py: Python, m: &Bound<PyModule>) -> PyResult<()> {
    // Forward Rust `log` records to Python's `logging` so messages emitted
    // from the extension reach the same handlers as the Python code. Ignore
    // the error if a logger is already installed (e.g. on module re-import).
    let _ = pyo3_log::try_init();
    m.add_wrapped(wrap_pyfunction!(_next_id_suffix))?;
    m.add_wrapped(wrap_pyfunction!(gen_file_id))?;
    m.add_wrapped(wrap_pyfunction!(gen_root_id))?;
    m.add_wrapped(wrap_pyfunction!(gen_revision_id))?;
    let errorsm = errors::errors_module(py)?;
    m.add_submodule(&errorsm)?;
    // Register the errors submodule in sys.modules immediately: other
    // submodules built below (e.g. inventory) define exception types whose
    // base classes are `import_exception!(bzrformats._bzr_rs.errors, ...)`,
    // which is resolved at class-creation time and would otherwise fail.
    {
        let sys = py.import("sys")?;
        let modules = sys.getattr("modules")?;
        modules.set_item(format!("{}.errors", m.name()?), &errorsm)?;
    }
    let m_globbing = PyModule::new(py, "globbing")?;
    m_globbing.add_wrapped(wrap_pyfunction!(normalize_pattern))?;
    m_globbing.add_class::<Replacer>()?;
    m.add_submodule(&m_globbing)?;
    m.add_class::<Revision>()?;
    m.add_class::<RevisionV4>()?;
    let inventorym = inventory::_inventory_rs(py)?;
    m.add_submodule(&inventorym)?;
    m.add_class::<RevisionSerializer>()?;
    m.add_class::<BEncodeRevisionSerializerv1>()?;
    m.add_class::<XMLRevisionSerializer4>()?;
    m.add_class::<XMLRevisionSerializer5>()?;
    m.add_class::<XMLRevisionSerializer8>()?;
    m.add_class::<InventorySerializer>()?;
    m.add_class::<XMLInventorySerializer4>()?;
    m.add_class::<XMLInventorySerializer5>()?;
    m.add_class::<XMLInventorySerializer6>()?;
    m.add_class::<XMLInventorySerializer7>()?;
    m.add_class::<XMLInventorySerializer8>()?;
    m.add_class::<CHKInventorySerializer>()?;
    m.add(
        "revision_bencode_serializer",
        m.getattr("BEncodeRevisionSerializerv1")?.call0()?,
    )?;
    m.add(
        "revision_serializer_v8",
        m.getattr("XMLRevisionSerializer8")?.call0()?,
    )?;
    m.add(
        "revision_serializer_v5",
        m.getattr("XMLRevisionSerializer5")?.call0()?,
    )?;
    m.add(
        "revision_serializer_v4",
        m.getattr("XMLRevisionSerializer4")?.call0()?,
    )?;
    m.add(
        "inventory_serializer_v4",
        m.getattr("XMLInventorySerializer4")?.call0()?,
    )?;
    m.add(
        "inventory_serializer_v5",
        m.getattr("XMLInventorySerializer5")?.call0()?,
    )?;
    m.add(
        "inventory_serializer_v6",
        m.getattr("XMLInventorySerializer6")?.call0()?,
    )?;
    m.add(
        "inventory_serializer_v7",
        m.getattr("XMLInventorySerializer7")?.call0()?,
    )?;
    m.add(
        "inventory_serializer_v8",
        m.getattr("XMLInventorySerializer8")?.call0()?,
    )?;
    // CHK inventory serializers: same wire format but parameterised
    // over format number, max page size, and search-key name. Two
    // instances are pre-built for the (9, 10) formats currently in use.
    m.add(
        "inventory_chk_serializer_255_bigpage_9",
        m.getattr("CHKInventorySerializer")?.call1((
            PyBytes::new(py, b"9"),
            65536u32,
            PyBytes::new(py, b"hash-255-way"),
        ))?,
    )?;
    m.add(
        "inventory_chk_serializer_255_bigpage_10",
        m.getattr("CHKInventorySerializer")?.call1((
            PyBytes::new(py, b"10"),
            65536u32,
            PyBytes::new(py, b"hash-255-way"),
        ))?,
    )?;
    m.add("CURRENT_REVISION", bazaar::CURRENT_REVISION)?;
    m.add("NULL_REVISION", bazaar::NULL_REVISION)?;
    m.add("ROOT_ID", bazaar::inventory::ROOT_ID)?;
    m.add_wrapped(wrap_pyfunction!(is_null_revision))?;
    m.add_wrapped(wrap_pyfunction!(is_reserved_revision_id))?;
    m.add_wrapped(wrap_pyfunction!(check_not_reserved_id))?;
    m.add_wrapped(wrap_pyfunction!(escape_invalid_chars))?;
    m.add_wrapped(wrap_pyfunction!(encode_and_escape))?;
    m.add_wrapped(wrap_pyfunction!(_unescape_xml))?;

    let riom = PyModule::new(py, "rio")?;
    rio::rio(&riom)?;
    m.add_submodule(&riom)?;

    let hashcachem = PyModule::new(py, "hashcache")?;
    hashcache::hashcache(&hashcachem)?;
    m.add_submodule(&hashcachem)?;

    let dirstatem = dirstate::_dirstate_rs(py)?;
    m.add_submodule(&dirstatem)?;

    let lockm = lock::_lock_rs(py)?;
    m.add_submodule(&lockm)?;

    let lru_cachem = lru_cache::_lru_cache_rs(py)?;
    m.add_submodule(&lru_cachem)?;

    let groupcompressm = groupcompress::_groupcompress_rs(py)?;
    m.add_submodule(&groupcompressm)?;

    let chk_mapm = chk_map::_chk_map_rs(py)?;
    m.add_submodule(&chk_mapm)?;

    let knitm = knit::_knit_rs(py)?;
    m.add_submodule(&knitm)?;

    let smartm = smart::_smart_rs(py)?;
    m.add_submodule(&smartm)?;

    let textmergem = textmerge::_textmerge_rs(py)?;
    m.add_submodule(&textmergem)?;

    let testamentm = testament::_testament_rs(py)?;
    m.add_submodule(&testamentm)?;

    let textinvm = textinv::_textinv_rs(py)?;
    m.add_submodule(&textinvm)?;

    let controldirm = controldir::_controldir_rs(py)?;
    m.add_submodule(&controldirm)?;

    let multiparentm = multiparent::_multiparent_rs(py)?;
    m.add_submodule(&multiparentm)?;

    let weavem = weave::_weave_rs(py)?;
    m.add_submodule(&weavem)?;
    let weavefilem = weavefile::_weavefile_rs(py)?;
    m.add_submodule(&weavefilem)?;

    let packm = pack::_pack_rs(py)?;
    m.add_submodule(&packm)?;

    let pack_repom = pack_repo::_pack_repo_rs(py)?;
    m.add_submodule(&pack_repom)?;

    let indexm = index::_index_rs(py)?;
    m.add_submodule(&indexm)?;

    let btree_indexm = btree_index::_btree_index_rs(py)?;
    m.add_submodule(&btree_indexm)?;

    let versionedfilem = versionedfile::_versionedfile_rs(py)?;
    m.add_submodule(&versionedfilem)?;

    let plan_mergem = plan_merge::_plan_merge_rs(py)?;
    m.add_submodule(&plan_mergem)?;
    let recordcounterm = recordcounter::_recordcounter_rs(py)?;
    m.add_submodule(&recordcounterm)?;
    let annotatem = annotate::_annotate_rs(py)?;
    m.add_submodule(&annotatem)?;

    let btree_serializerm = btree_serializer::_btree_serializer_rs(py)?;
    m.add_submodule(&btree_serializerm)?;

    let bisect_multim = bisect_multi::_bisect_multi_rs(py)?;
    m.add_submodule(&bisect_multim)?;

    let chunk_writerm = chunk_writer::_chunk_writer_rs(py)?;
    m.add_submodule(&chunk_writerm)?;

    let tuned_gzipm = tuned_gzip::_tuned_gzip_rs(py)?;
    m.add_submodule(&tuned_gzipm)?;

    let osutilsm = PyModule::new(py, "osutils")?;
    osutils::_osutils_rs(py, &osutilsm)?;
    m.add_submodule(&osutilsm)?;

    let chunk_writerm = chunk_writer::_chunk_writer_rs(py)?;
    m.add_submodule(&chunk_writerm)?;

    let bisect_multim = bisect_multi::_bisect_multi_rs(py)?;
    m.add_submodule(&bisect_multim)?;

    // PyO3 submodule hack for proper import support
    let sys = py.import("sys")?;
    let modules = sys.getattr("modules")?;
    let module_name = m.name()?;

    // Register submodules in sys.modules for dotted import support
    modules.set_item(format!("{}.globbing", module_name), &m_globbing)?;
    modules.set_item(format!("{}.inventory", module_name), &inventorym)?;
    modules.set_item(format!("{}.rio", module_name), &riom)?;
    modules.set_item(format!("{}.hashcache", module_name), &hashcachem)?;
    modules.set_item(format!("{}.dirstate", module_name), &dirstatem)?;
    modules.set_item(format!("{}.lock", module_name), &lockm)?;
    modules.set_item(format!("{}.lru_cache", module_name), &lru_cachem)?;
    modules.set_item(format!("{}.groupcompress", module_name), &groupcompressm)?;
    modules.set_item(format!("{}.chk_map", module_name), &chk_mapm)?;
    modules.set_item(format!("{}.knit", module_name), &knitm)?;
    modules.set_item(format!("{}.smart", module_name), &smartm)?;
    modules.set_item(format!("{}.textmerge", module_name), &textmergem)?;
    modules.set_item(format!("{}.testament", module_name), &testamentm)?;
    modules.set_item(format!("{}.textinv", module_name), &textinvm)?;
    modules.set_item(format!("{}.controldir", module_name), &controldirm)?;
    modules.set_item(format!("{}.multiparent", module_name), &multiparentm)?;
    modules.set_item(format!("{}.weave", module_name), &weavem)?;
    modules.set_item(format!("{}.weavefile", module_name), &weavefilem)?;
    modules.set_item(format!("{}.pack", module_name), &packm)?;
    modules.set_item(format!("{}.pack_repo", module_name), &pack_repom)?;
    modules.set_item(format!("{}.index", module_name), &indexm)?;
    modules.set_item(format!("{}.btree_index", module_name), &btree_indexm)?;
    modules.set_item(format!("{}.versionedfile", module_name), &versionedfilem)?;
    modules.set_item(format!("{}.plan_merge", module_name), &plan_mergem)?;
    modules.set_item(format!("{}.recordcounter", module_name), &recordcounterm)?;
    modules.set_item(format!("{}.annotate", module_name), &annotatem)?;
    modules.set_item(
        format!("{}.btree_serializer", module_name),
        &btree_serializerm,
    )?;
    modules.set_item(format!("{}.bisect_multi", module_name), &bisect_multim)?;
    modules.set_item(format!("{}.chunk_writer", module_name), &chunk_writerm)?;
    modules.set_item(format!("{}.tuned_gzip", module_name), &tuned_gzipm)?;
    modules.set_item(format!("{}.osutils", module_name), &osutilsm)?;
    modules.set_item(format!("{}.chunk_writer", module_name), &chunk_writerm)?;
    modules.set_item(format!("{}.bisect_multi", module_name), &bisect_multim)?;

    register_bzrformats_modules(py, m, &packm, &weavem, &btree_indexm, &btree_serializerm)?;

    Ok(())
}

/// Build the public `bzrformats.<name>` modules that used to be one-line Python
/// re-export shims, registering them in `sys.modules` and as attributes of the
/// top-level `bzrformats` package. Each module exposes EXACTLY the names the
/// former Python shim did, composed from the Rust extension's objects.
fn register_bzrformats_modules(
    py: Python<'_>,
    m: &Bound<'_, PyModule>,
    packm: &Bound<'_, PyModule>,
    weavem: &Bound<'_, PyModule>,
    btree_indexm: &Bound<'_, PyModule>,
    btree_serializerm: &Bound<'_, PyModule>,
) -> PyResult<()> {
    let sys = py.import("sys")?;
    let modules = sys.getattr("modules")?;
    // The top-level `bzrformats` package object (already importing us).
    let pkg = py.import("bzrformats")?;
    let errors = py.import("bzrformats._bzr_rs.errors")?;

    // Build one module from (public_name, object) pairs and register it under
    // `bzrformats.<name>` plus as an attribute of the bzrformats package.
    let build = |name: &str, items: &[(&str, Bound<'_, PyAny>)]| -> PyResult<()> {
        let module = PyModule::new(py, name)?;
        for (attr, obj) in items {
            module.add(*attr, obj.clone())?;
        }
        modules.set_item(format!("bzrformats.{}", name), &module)?;
        pkg.setattr(name, &module)?;
        Ok(())
    };

    let logging = py.import("logging")?;

    // --- xml serializer family ---
    build(
        "xml4",
        &[
            (
                "inventory_serializer_v4",
                m.getattr("inventory_serializer_v4")?,
            ),
            (
                "revision_serializer_v4",
                m.getattr("revision_serializer_v4")?,
            ),
        ],
    )?;
    build(
        "xml5",
        &[
            (
                "inventory_serializer_v5",
                m.getattr("inventory_serializer_v5")?,
            ),
            (
                "revision_serializer_v5",
                m.getattr("revision_serializer_v5")?,
            ),
        ],
    )?;
    build(
        "xml6",
        &[(
            "inventory_serializer_v6",
            m.getattr("inventory_serializer_v6")?,
        )],
    )?;
    build(
        "xml7",
        &[(
            "inventory_serializer_v7",
            m.getattr("inventory_serializer_v7")?,
        )],
    )?;
    build(
        "xml8",
        &[
            (
                "inventory_serializer_v8",
                m.getattr("inventory_serializer_v8")?,
            ),
            (
                "revision_serializer_v8",
                m.getattr("revision_serializer_v8")?,
            ),
            ("_unescape_xml", m.getattr("_unescape_xml")?),
        ],
    )?;
    build(
        "xml_serializer",
        &[
            ("encode_and_escape", m.getattr("encode_and_escape")?),
            ("escape_invalid_chars", m.getattr("escape_invalid_chars")?),
        ],
    )?;
    build(
        "chk_serializer",
        &[
            ("CHKSerializer", m.getattr("CHKInventorySerializer")?),
            (
                "inventory_chk_serializer_255_bigpage_9",
                m.getattr("inventory_chk_serializer_255_bigpage_9")?,
            ),
            (
                "inventory_chk_serializer_255_bigpage_10",
                m.getattr("inventory_chk_serializer_255_bigpage_10")?,
            ),
        ],
    )?;

    // --- generate_ids / revision ---
    build(
        "generate_ids",
        &[
            ("_next_id_suffix", m.getattr("_next_id_suffix")?),
            ("gen_file_id", m.getattr("gen_file_id")?),
            ("gen_revision_id", m.getattr("gen_revision_id")?),
            ("gen_root_id", m.getattr("gen_root_id")?),
        ],
    )?;
    {
        // revision.py defines RevisionID = bytes and Revision = BzrRevision.
        let revision_cls = m.getattr("Revision")?;
        build(
            "revision",
            &[
                ("CURRENT_REVISION", m.getattr("CURRENT_REVISION")?),
                ("NULL_REVISION", m.getattr("NULL_REVISION")?),
                ("check_not_reserved_id", m.getattr("check_not_reserved_id")?),
                ("is_null", m.getattr("is_null")?),
                ("is_reserved_id", m.getattr("is_reserved_id")?),
                ("BzrRevision", revision_cls.clone()),
                ("Revision", revision_cls.clone()),
                ("RevisionID", py.get_type::<PyBytes>().into_any()),
            ],
        )?;
    }

    // --- merge / textmerge / rio_patch (curated subsets of existing submodules) ---
    {
        let plan_merge = py.import("bzrformats._bzr_rs.plan_merge")?;
        build(
            "merge",
            &[
                ("_PlanMerge", plan_merge.getattr("_PlanMerge")?),
                ("_PlanLCAMerge", plan_merge.getattr("_PlanLCAMerge")?),
            ],
        )?;
    }
    {
        let textmerge = py.import("bzrformats._bzr_rs.textmerge")?;
        build(
            "textmerge",
            &[
                ("Merge2", textmerge.getattr("Merge2")?),
                ("TextMerge", textmerge.getattr("TextMerge")?),
            ],
        )?;
    }
    {
        let rio = py.import("bzrformats._bzr_rs.rio")?;
        build(
            "rio",
            &[
                ("RioReader", rio.getattr("RioReader")?),
                ("RioWriter", rio.getattr("RioWriter")?),
                ("Stanza", rio.getattr("Stanza")?),
                ("read_stanza", rio.getattr("read_stanza")?),
                ("read_stanza_file", rio.getattr("read_stanza_file")?),
                ("read_stanzas", rio.getattr("read_stanzas")?),
                ("rio_iter", rio.getattr("rio_iter")?),
                ("valid_tag", rio.getattr("valid_tag")?),
            ],
        )?;
        build(
            "rio_patch",
            &[
                ("read_patch_stanza", rio.getattr("read_patch_stanza")?),
                ("to_patch_lines", rio.getattr("to_patch_lines")?),
            ],
        )?;
    }

    // --- inventory_delta ---
    {
        let inv = py.import("bzrformats._bzr_rs.inventory")?;
        build(
            "inventory_delta",
            &[
                ("InventoryDeltaError", inv.getattr("InventoryDeltaError")?),
                (
                    "IncompatibleInventoryDelta",
                    inv.getattr("IncompatibleInventoryDelta")?,
                ),
                (
                    "parse_inventory_entry",
                    inv.getattr("parse_inventory_entry")?,
                ),
                (
                    "serialize_inventory_entry",
                    inv.getattr("serialize_inventory_entry")?,
                ),
                ("InventoryDelta", inv.getattr("InventoryDelta")?),
                (
                    "InventoryDeltaSerializer",
                    inv.getattr("InventoryDeltaSerializer")?,
                ),
                (
                    "InventoryDeltaDeserializer",
                    inv.getattr("InventoryDeltaDeserializer")?,
                ),
            ],
        )?;
    }

    // --- pack (re-exports its submodule names + the container errors) ---
    build(
        "pack",
        &[
            ("FORMAT_ONE", packm.getattr("FORMAT_ONE")?),
            ("_check_name", packm.getattr("_check_name")?),
            (
                "_check_name_encoding",
                packm.getattr("_check_name_encoding")?,
            ),
            ("ContainerSerialiser", packm.getattr("ContainerSerialiser")?),
            ("ContainerWriter", packm.getattr("ContainerWriter")?),
            ("ContainerReader", packm.getattr("ContainerReader")?),
            ("BytesRecordReader", packm.getattr("BytesRecordReader")?),
            ("ContainerPushParser", packm.getattr("ContainerPushParser")?),
            ("ReadVFile", packm.getattr("ReadVFile")?),
            ("make_readv_reader", packm.getattr("make_readv_reader")?),
            (
                "iter_records_from_file",
                packm.getattr("iter_records_from_file")?,
            ),
            ("ContainerError", errors.getattr("ContainerError")?),
            (
                "ContainerHasExcessDataError",
                errors.getattr("ContainerHasExcessDataError")?,
            ),
            (
                "DuplicateRecordNameError",
                errors.getattr("DuplicateRecordNameError")?,
            ),
            ("InvalidRecordError", errors.getattr("InvalidRecordError")?),
            (
                "UnexpectedEndOfContainerError",
                errors.getattr("UnexpectedEndOfContainerError")?,
            ),
            (
                "UnknownContainerFormatError",
                errors.getattr("UnknownContainerFormatError")?,
            ),
            (
                "UnknownRecordTypeError",
                errors.getattr("UnknownRecordTypeError")?,
            ),
        ],
    )?;

    // --- weave (core classes + the weave error hierarchy) ---
    build(
        "weave",
        &[
            ("Weave", weavem.getattr("Weave")?),
            ("WeaveFile", weavem.getattr("WeaveFile")?),
            (
                "WeaveContentFactory",
                weavem.getattr("WeaveContentFactory")?,
            ),
            ("WeaveError", errors.getattr("WeaveError")?),
            ("WeaveFormatError", errors.getattr("WeaveFormatError")?),
            (
                "WeaveInvalidChecksum",
                errors.getattr("WeaveInvalidChecksum")?,
            ),
            (
                "WeaveParentMismatch",
                errors.getattr("WeaveParentMismatch")?,
            ),
            (
                "WeaveRevisionAlreadyPresent",
                errors.getattr("WeaveRevisionAlreadyPresent")?,
            ),
            (
                "WeaveRevisionNotPresent",
                errors.getattr("WeaveRevisionNotPresent")?,
            ),
            ("WeaveTextDiffers", errors.getattr("WeaveTextDiffers")?),
        ],
    )?;

    // --- btree_index (classes/constants from the submodule + the chk factory
    //     from btree_serializer + the byte constants and loggers). pyo3-log
    //     forwards Rust log records, but the module still exposes named
    //     `logging` loggers as attributes for API compatibility. ---
    build(
        "btree_index",
        &[
            ("BTreeBuilder", btree_indexm.getattr("BTreeBuilder")?),
            ("BTreeGraphIndex", btree_indexm.getattr("BTreeGraphIndex")?),
            ("_LeafNode", btree_indexm.getattr("_LeafNode")?),
            ("_InternalNode", btree_indexm.getattr("_InternalNode")?),
            ("PAGE_SIZE", btree_indexm.getattr("PAGE_SIZE")?),
            ("_PAGE_SIZE", btree_indexm.getattr("_PAGE_SIZE")?),
            (
                "_gcchk_factory",
                btree_serializerm.getattr("_parse_into_chk")?,
            ),
            ("_btree_serializer", btree_serializerm.clone().into_any()),
            (
                "_BTSIGNATURE",
                PyBytes::new(py, b"B+Tree Graph Index 2\n").into_any(),
            ),
            ("_LEAF_FLAG", PyBytes::new(py, b"type=leaf\n").into_any()),
            (
                "_INTERNAL_FLAG",
                PyBytes::new(py, b"type=internal\n").into_any(),
            ),
            (
                "logger",
                logging.call_method1("getLogger", ("bzrformats.btree_index",))?,
            ),
            (
                "evil_logger",
                logging.call_method1("getLogger", ("bzrformats.evil",))?,
            ),
        ],
    )?;

    Ok(())
}
