#![allow(non_snake_case)]

use bazaar::FileId;
use pyo3::exceptions::PyTypeError;
use pyo3::prelude::*;
use pyo3::types::{PyBytes, PyDict, PyList, PyString, PyTuple};
use pyo3::wrap_pyfunction;
#[cfg(unix)]
use std::ffi::OsString;
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
use std::path::{Path, PathBuf};

pyo3::import_exception!(bzrformats._bzr_rs.errors, NotVersionedError);
pyo3::import_exception!(bzrformats._bzr_rs.errors, BzrFormatsError);
pyo3::import_exception!(bzrformats._bzr_rs.errors, InconsistentDelta);
pyo3::import_exception!(bzrformats._bzr_rs.errors, InvalidNormalization);
pyo3::import_exception!(bzrformats._bzr_rs.errors, BadFileKindError);
pyo3::import_exception!(bzrformats._bzr_rs.errors, LockContention);
pyo3::import_exception!(bzrformats._bzr_rs.errors, LockNotHeld);
pyo3::import_exception!(bzrformats._bzr_rs.errors, ObjectNotLocked);
pyo3::import_exception!(bzrformats.inventory, DuplicateFileId);
pyo3::import_exception!(bzrformats.inventory, InvalidEntryName);
pyo3::import_exception!(bzrformats._bzr_rs.errors, DirstateCorrupt);

/// `bazaar::dirstate::Transport` adapter backed by a Python file-like
/// object.  Used by `DirStateRs.save_to_file` so the pure-Rust
/// `DirState::save_to` flow can handle the write+fdatasync+state
/// bookkeeping while Python retains ownership of the file descriptor
/// and the OS-level lock (both managed by `bzrformats.lock`).
///
/// The adapter is *told* its lock state at construction time — it
/// does not acquire or release locks itself.  Callers should hold a
/// write lock through `bzrformats.lock.WriteLock` (or the temporary
/// upgrade dance inside `ReadLock.temporary_write_lock`) before
/// creating one with `LockState::Write`.
struct PyFileTransport {
    file: Py<PyAny>,
    lock_state: Option<bazaar::dirstate::LockState>,
}

impl PyFileTransport {
    fn new(file: Py<PyAny>, lock_state: bazaar::dirstate::LockState) -> Self {
        Self {
            file,
            lock_state: Some(lock_state),
        }
    }

    fn map_err(py: Python<'_>, err: PyErr) -> bazaar::dirstate::TransportError {
        bazaar::dirstate::TransportError::Other(err.value(py).to_string())
    }

    /// Run `f` against a `std::fs::File` borrowing the Python file's fd.
    ///
    /// The file descriptor is owned by the Python file object; the
    /// borrowed `File` is wrapped in `ManuallyDrop` so it is never
    /// closed when `f` returns. Used for fd-level operations (fstat,
    /// fdatasync) that std exposes through `File`.
    fn with_borrowed_file<T>(
        &self,
        f: impl FnOnce(&std::fs::File) -> Result<T, bazaar::dirstate::TransportError>,
    ) -> Result<T, bazaar::dirstate::TransportError> {
        Python::attach(|py| {
            let fileno: std::os::raw::c_int = self
                .file
                .bind(py)
                .call_method0("fileno")
                .map_err(|e| Self::map_err(py, e))?
                .extract()
                .map_err(|e| Self::map_err(py, e))?;
            // SAFETY: `fileno` is a live fd owned by the Python file
            // object for the duration of this call. `ManuallyDrop`
            // prevents the borrowed `File` from closing it.
            #[cfg(unix)]
            let file = {
                use std::os::fd::FromRawFd;
                std::mem::ManuallyDrop::new(unsafe {
                    std::fs::File::from_raw_fd(fileno as std::os::fd::RawFd)
                })
            };
            // On Windows `fileno()` is a CRT file descriptor; translate it
            // to the underlying OS handle via `msvcrt.get_osfhandle` before
            // building a borrowed `File`.
            #[cfg(windows)]
            let file = {
                use std::os::windows::io::{FromRawHandle, RawHandle};
                let handle: isize = py
                    .import("msvcrt")
                    .and_then(|m| m.call_method1("get_osfhandle", (fileno,)))
                    .and_then(|h| h.extract())
                    .map_err(|e| Self::map_err(py, e))?;
                std::mem::ManuallyDrop::new(unsafe {
                    std::fs::File::from_raw_handle(handle as RawHandle)
                })
            };
            f(&file)
        })
    }
}

impl bazaar::dirstate::Transport for PyFileTransport {
    fn exists(&self) -> Result<bool, bazaar::dirstate::TransportError> {
        // The caller already has an open fd; the file exists by
        // construction.
        Ok(true)
    }

    fn lock_read(&mut self) -> Result<(), bazaar::dirstate::TransportError> {
        if self.lock_state.is_some() {
            return Err(bazaar::dirstate::TransportError::AlreadyLocked);
        }
        self.lock_state = Some(bazaar::dirstate::LockState::Read);
        Ok(())
    }

    fn lock_write(&mut self) -> Result<(), bazaar::dirstate::TransportError> {
        if self.lock_state.is_some() {
            return Err(bazaar::dirstate::TransportError::AlreadyLocked);
        }
        self.lock_state = Some(bazaar::dirstate::LockState::Write);
        Ok(())
    }

    fn unlock(&mut self) -> Result<(), bazaar::dirstate::TransportError> {
        if self.lock_state.is_none() {
            return Err(bazaar::dirstate::TransportError::NotLocked);
        }
        self.lock_state = None;
        Ok(())
    }

    fn lock_state(&self) -> Option<bazaar::dirstate::LockState> {
        self.lock_state
    }

    fn read_all(&mut self) -> Result<Vec<u8>, bazaar::dirstate::TransportError> {
        if self.lock_state.is_none() {
            return Err(bazaar::dirstate::TransportError::NotLocked);
        }
        Python::attach(|py| -> Result<Vec<u8>, bazaar::dirstate::TransportError> {
            let f = self.file.bind(py);
            f.call_method1("seek", (0,))
                .map_err(|e| Self::map_err(py, e))?;
            let data = f.call_method0("read").map_err(|e| Self::map_err(py, e))?;
            let bytes = data.cast_into::<PyBytes>().map_err(|_| {
                bazaar::dirstate::TransportError::Other(
                    "file.read() did not return bytes".to_string(),
                )
            })?;
            Ok(bytes.as_bytes().to_vec())
        })
    }

    fn read_at(
        &mut self,
        offset: u64,
        len: usize,
    ) -> Result<Vec<u8>, bazaar::dirstate::TransportError> {
        if self.lock_state.is_none() {
            return Err(bazaar::dirstate::TransportError::NotLocked);
        }
        Python::attach(|py| -> Result<Vec<u8>, bazaar::dirstate::TransportError> {
            let f = self.file.bind(py);
            f.call_method1("seek", (offset,))
                .map_err(|e| Self::map_err(py, e))?;
            let data = f
                .call_method1("read", (len,))
                .map_err(|e| Self::map_err(py, e))?;
            let bytes = data.cast_into::<PyBytes>().map_err(|_| {
                bazaar::dirstate::TransportError::Other(
                    "file.read() did not return bytes".to_string(),
                )
            })?;
            Ok(bytes.as_bytes().to_vec())
        })
    }

    fn len(&mut self) -> Result<u64, bazaar::dirstate::TransportError> {
        if self.lock_state.is_none() {
            return Err(bazaar::dirstate::TransportError::NotLocked);
        }
        // Python owns the fd; borrow it for an fstat via std::fs without
        // taking ownership (the file must not be closed here).
        self.with_borrowed_file(|file| Ok(file.metadata()?.len()))
    }

    fn write_all(&mut self, bytes: &[u8]) -> Result<(), bazaar::dirstate::TransportError> {
        if self.lock_state != Some(bazaar::dirstate::LockState::Write) {
            return Err(bazaar::dirstate::TransportError::Other(
                "write_all requires a write lock".to_string(),
            ));
        }
        Python::attach(|py| -> Result<(), bazaar::dirstate::TransportError> {
            let f = self.file.bind(py);
            f.call_method1("seek", (0,))
                .map_err(|e| Self::map_err(py, e))?;
            let py_bytes = PyBytes::new(py, bytes);
            f.call_method1("write", (py_bytes,))
                .map_err(|e| Self::map_err(py, e))?;
            f.call_method0("truncate")
                .map_err(|e| Self::map_err(py, e))?;
            f.call_method0("flush").map_err(|e| Self::map_err(py, e))?;
            Ok(())
        })
    }

    fn fdatasync(&mut self) -> Result<(), bazaar::dirstate::TransportError> {
        // `File::sync_data` is `fdatasync(2)`; the platform falls back to
        // `fsync` where `fdatasync` is unavailable, matching
        // `bzrformats.osutils.fdatasync`.
        self.with_borrowed_file(|file| {
            file.sync_data()?;
            Ok(())
        })
    }

    fn lstat(
        &self,
        abspath: &[u8],
    ) -> Result<bazaar::dirstate::StatInfo, bazaar::dirstate::TransportError> {
        // Path-based: stat the filesystem directly rather than calling
        // back into Python's `os.lstat`.
        bazaar::dirstate::lstat_path(abspath)
    }

    fn read_link(&self, abspath: &[u8]) -> Result<Vec<u8>, bazaar::dirstate::TransportError> {
        bazaar::dirstate::read_link_path(abspath)
    }

    fn list_dir(
        &self,
        abspath: &[u8],
    ) -> Result<Vec<bazaar::dirstate::DirEntryInfo>, bazaar::dirstate::TransportError> {
        bazaar::dirstate::list_dir_path(abspath)
    }

    fn is_tree_reference_dir(
        &self,
        abspath: &[u8],
    ) -> Result<bool, bazaar::dirstate::TransportError> {
        // A directory is a potential tree reference when it carries its
        // own `.bzr/`. Mirrors breezy's
        // `_directory_may_be_tree_reference`; the empty-path guard keeps
        // the tree root from ever counting as a reference.
        bazaar::dirstate::is_tree_reference_dir_path(abspath)
    }
}

/// Decode a minikind from the first byte of a Python-supplied
/// `bytes` object, raising `ValueError` on an empty slice or unknown
/// byte.  Used by every pyo3 entry that accepts a minikind slice from
/// Python.
/// Helper: marshal a Python `InventoryEntry` into the 5-tuple shape
/// `inv_entry_to_details` returns: `(minikind_byte, fingerprint,
/// size, executable, packed_stat)`. Pulls the entry's fields through
/// pyo3's `InventoryEntry` extractor and forwards to the pure-Rust
/// `bazaar::dirstate::inv_entry_to_details`.
fn inv_entry_to_details_tuple<'py>(
    py: Python<'py>,
    inv_entry: &Bound<'py, PyAny>,
) -> PyResult<Bound<'py, PyTuple>> {
    let extracted = inv_entry.extract::<pyo3::PyRef<'_, crate::inventory::InventoryEntry>>()?;
    let (minikind, fingerprint, size, executable, packed_stat) =
        bazaar::dirstate::inv_entry_to_details(&extracted.0);
    PyTuple::new(
        py,
        [
            PyBytes::new(py, &[minikind.to_minikind()]).into_any(),
            PyBytes::new(py, fingerprint.as_slice()).into_any(),
            size.into_pyobject(py)?.into_any(),
            pyo3::types::PyBool::new(py, executable)
                .to_owned()
                .into_any(),
            PyBytes::new(py, packed_stat.as_slice()).into_any(),
        ],
    )
}

/// Map a kind name (`"file"`, `"directory"`, `"symlink"`,
/// `"tree-reference"`, `"absent"`, `"relocated"`) to the corresponding
/// dirstate `Kind`. Used when marshalling inventory entries.
fn kind_to_minikind(kind: &str) -> PyResult<bazaar::dirstate::Kind> {
    match kind {
        "file" => Ok(bazaar::dirstate::Kind::File),
        "directory" => Ok(bazaar::dirstate::Kind::Directory),
        "symlink" => Ok(bazaar::dirstate::Kind::Symlink),
        "tree-reference" => Ok(bazaar::dirstate::Kind::TreeReference),
        "absent" => Ok(bazaar::dirstate::Kind::Absent),
        "relocated" => Ok(bazaar::dirstate::Kind::Relocated),
        other => Err(PyTypeError::new_err(format!("unknown kind: {other}"))),
    }
}

fn decode_minikind(bytes: &[u8]) -> PyResult<bazaar::dirstate::Kind> {
    let byte = bytes
        .first()
        .copied()
        .ok_or_else(|| pyo3::exceptions::PyValueError::new_err("empty minikind"))?;
    bazaar::dirstate::Kind::from_minikind(byte).map_err(|b| {
        pyo3::exceptions::PyValueError::new_err(format!("invalid minikind byte {:?}", b))
    })
}

/// Spell out the kind name for an `st_mode`, matching breezy's
/// `_readdir_py._formats` mapping.  Used to format
/// `BadFileKindError` payloads for kinds the dirstate can't track.
fn kind_name_from_mode(mode: u32) -> &'static str {
    match mode & 0o170000 {
        0o010000 => "fifo",
        0o020000 => "chardev",
        0o040000 => "directory",
        0o060000 => "block",
        0o100000 => "file",
        0o120000 => "symlink",
        0o140000 => "socket",
        _ => "unknown",
    }
}

/// Build a `bzrformats.errors.BadFileKindError` for `path` (utf-8
/// bytes) and the raw stat mode.  Surfaces the kinds the walker
/// cannot represent (fifo, socket, …) without coupling the pure
/// crate to the Python error class.
fn bad_file_kind_error(_py: Python<'_>, path: &[u8], mode: u32) -> PyErr {
    let path_str = String::from_utf8_lossy(path).into_owned();
    let kind = kind_name_from_mode(mode);
    BadFileKindError::new_err((path_str, kind))
}

/// Translate a `BisectError` into the appropriate Python exception:
/// genuine I/O failures become `OSError`, anything else (bad minikind,
/// bad size field, too many seeks while parsing) is dirstate corruption
/// and is raised as `DirstateCorrupt(state, msg)` so callers can catch
/// a single class for "the dirstate is unreadable".
fn bisect_err_to_py(state: &Bound<PyAny>, err: bazaar::dirstate::BisectError) -> PyErr {
    match err {
        bazaar::dirstate::BisectError::ReadError(s) => pyo3::exceptions::PyOSError::new_err(s),
        other => DirstateCorrupt::new_err((state.clone().unbind(), other.to_string())),
    }
}

/// Map a [`bazaar::dirstate::TransportError`] into the Python
/// exception class the dirstate API used to raise. `LockContention`
/// / `NotLocked` / `AlreadyLocked` become their named counterparts;
/// the rest fall through to `BzrFormatsError` so callers can still
/// catch.
/// Read the dirstate header and dirblocks from the transport iff they
/// have not already been loaded. Mirrors Python's
/// `DirState._read_dirblocks_if_needed`. Takes the `Bound<PyDirState>`
/// because the body-parsing step needs to call back into the pyo3
/// helper that produces the Python-side `_dirblocks` aliasing
/// representation.
fn read_dirblocks_if_needed(py: Python<'_>, slf: &Bound<'_, PyDirState>) -> PyResult<()> {
    PyDirState::_read_header_if_needed(slf.clone())?;
    let needs_blocks = matches!(
        slf.borrow().inner.dirblock_state,
        bazaar::dirstate::MemoryState::NotInMemory
    );
    if !needs_blocks {
        return Ok(());
    }
    crate::dirstate_helpers::_read_dirblocks(py, slf.as_any())
}

fn transport_err_to_py(err: bazaar::dirstate::TransportError) -> PyErr {
    use bazaar::dirstate::TransportError;
    match err {
        TransportError::LockContention(p) => LockContention::new_err(p),
        TransportError::NotLocked => LockNotHeld::new_err(()),
        TransportError::AlreadyLocked => LockContention::new_err("already locked"),
        TransportError::NotFound(p) => pyo3::exceptions::PyFileNotFoundError::new_err(p),
        TransportError::Io { message, .. } => pyo3::exceptions::PyOSError::new_err(message),
        TransportError::Other(msg) => BzrFormatsError::new_err(msg),
    }
}

/// Convert the `bisect` / `bisect_dirblocks` result into a Python
/// dict: `{path_bytes: [entry_tuple, ...]}` where `entry_tuple` has
/// the same shape as `DirStateRs.dirblocks` entries.
fn bisect_result_to_pydict<'py>(
    py: Python<'py>,
    found: &std::collections::HashMap<Vec<u8>, Vec<bazaar::dirstate::Entry>>,
) -> PyResult<Bound<'py, PyDict>> {
    let out = PyDict::new(py);
    for (key, entries) in found {
        let mut py_entries: Vec<Bound<PyAny>> = Vec::with_capacity(entries.len());
        for entry in entries {
            let key_tuple = PyTuple::new(
                py,
                [
                    PyBytes::new(py, &entry.key.dirname).into_any(),
                    PyBytes::new(py, &entry.key.basename).into_any(),
                    PyBytes::new(py, &entry.key.file_id).into_any(),
                ],
            )?;
            let mut tree_list: Vec<Bound<PyAny>> = Vec::with_capacity(entry.trees.len());
            for t in &entry.trees {
                let tup = PyTuple::new(
                    py,
                    [
                        PyBytes::new(py, &[t.minikind.to_minikind()]).into_any(),
                        PyBytes::new(py, &t.fingerprint).into_any(),
                        t.size.into_pyobject(py)?.into_any(),
                        pyo3::types::PyBool::new(py, t.executable)
                            .to_owned()
                            .into_any(),
                        PyBytes::new(py, &t.packed_stat).into_any(),
                    ],
                )?;
                tree_list.push(tup.into_any());
            }
            let entry_tuple = PyTuple::new(
                py,
                [key_tuple.into_any(), PyList::new(py, tree_list)?.into_any()],
            )?;
            py_entries.push(entry_tuple.into_any());
        }
        out.set_item(PyBytes::new(py, key), PyList::new(py, py_entries)?)?;
    }
    Ok(out)
}

// TODO(jelmer): Shared pyo3 utils?
fn extract_path(object: &Bound<PyAny>) -> PyResult<PathBuf> {
    if let Ok(path) = object.extract::<Vec<u8>>() {
        #[cfg(unix)]
        {
            Ok(PathBuf::from(OsString::from_vec(path)))
        }
        #[cfg(not(unix))]
        {
            Ok(PathBuf::from(
                String::from_utf8(path).map_err(|e| PyTypeError::new_err(e.to_string()))?,
            ))
        }
    } else if let Ok(path) = object.extract::<PathBuf>() {
        Ok(path)
    } else {
        Err(PyTypeError::new_err("path must be a string or bytes"))
    }
}

/// Compare two paths directory by directory.
///
///  This is equivalent to doing::
///
///     operator.lt(path1.split('/'), path2.split('/'))
///
///  The idea is that you should compare path components separately. This
///  differs from plain ``path1 < path2`` for paths like ``'a-b'`` and ``a/b``.
///  "a-b" comes after "a" but would come before "a/b" lexically.
///
/// Args:
///  path1: first path
///  path2: second path
/// Returns: True if path1 comes first, otherwise False
#[pyfunction]
fn lt_by_dirs(path1: &Bound<PyAny>, path2: &Bound<PyAny>) -> PyResult<bool> {
    let path1 = extract_path(path1)?;
    let path2 = extract_path(path2)?;
    Ok(bazaar::dirstate::lt_by_dirs(&path1, &path2))
}

/// Return the index where to insert path into paths.
///
/// This uses the dirblock sorting. So all children in a directory come before
/// the children of children. For example::
///
///     a/
///       b/
///         c
///       d/
///         e
///       b-c
///       d-e
///     a-a
///     a=c
///
/// Will be sorted as::
///
///     a
///     a-a
///     a=c
///     a/b
///     a/b-c
///     a/d
///     a/d-e
///     a/b/c
///     a/d/e
///
/// Args:
///   paths: A list of paths to search through
///   path: A single path to insert
/// Returns: An offset where 'path' can be inserted.
/// See also: bisect.bisect_left

#[pyfunction]
fn bisect_path_left(paths: Vec<Bound<PyAny>>, path: &Bound<PyAny>) -> PyResult<usize> {
    let path = extract_path(path)?;
    let paths = paths
        .iter()
        .map(extract_path)
        .collect::<PyResult<Vec<PathBuf>>>()?;
    let offset = bazaar::dirstate::bisect_path_left(
        paths
            .iter()
            .map(|x| x.as_path())
            .collect::<Vec<&Path>>()
            .as_slice(),
        &path,
    );
    Ok(offset)
}

/// Return the index where to insert path into paths.
///
/// This uses a path-wise comparison so we get::
///     a
///     a-b
///     a=b
///     a/b
/// Rather than::
///     a
///     a-b
///     a/b
///     a=b
///
/// Args:
///   paths: A list of paths to search through
///   path: A single path to insert
/// Returns: An offset where 'path' can be inserted.
/// See also: bisect.bisect_right
#[pyfunction]
fn bisect_path_right(paths: Vec<Bound<PyAny>>, path: &Bound<PyAny>) -> PyResult<usize> {
    let path = extract_path(path)?;
    let paths = paths
        .iter()
        .map(extract_path)
        .collect::<PyResult<Vec<PathBuf>>>()?;
    let offset = bazaar::dirstate::bisect_path_right(
        paths
            .iter()
            .map(|x| x.as_path())
            .collect::<Vec<&Path>>()
            .as_slice(),
        &path,
    );
    Ok(offset)
}

#[pyfunction]
fn lt_path_by_dirblock(path1: &Bound<PyAny>, path2: &Bound<PyAny>) -> PyResult<bool> {
    let path1 = extract_path(path1)?;
    let path2 = extract_path(path2)?;
    Ok(bazaar::dirstate::lt_path_by_dirblock(&path1, &path2))
}

#[pyfunction]
#[pyo3(signature = (dirblocks, dirname, lo=None, hi=None, cache=None))]
fn bisect_dirblock(
    py: Python,
    dirblocks: &Bound<PyList>,
    dirname: &Bound<PyAny>,
    lo: Option<usize>,
    hi: Option<usize>,
    cache: Option<Bound<PyDict>>,
) -> PyResult<usize> {
    fn split_object(obj: &Bound<PyAny>) -> PyResult<Vec<PathBuf>> {
        if let Ok(py_str) = obj.extract::<Bound<PyString>>() {
            Ok(py_str
                .to_string()
                .split('/')
                .map(PathBuf::from)
                .collect::<Vec<_>>())
        } else if let Ok(py_bytes) = obj.extract::<Bound<PyBytes>>() {
            Ok(py_bytes
                .as_bytes()
                .split(|&byte| byte == b'/')
                .map(|s| PathBuf::from(String::from_utf8_lossy(s).to_string()))
                .collect::<Vec<_>>())
        } else {
            Err(PyTypeError::new_err("Not a PyBytes or PyString"))
        }
    }

    let hi = hi.unwrap_or(dirblocks.len());
    let cache = cache.unwrap_or_else(|| PyDict::new(py));

    let dirname_split = match cache.get_item(dirname)? {
        Some(item) => item.extract::<Vec<PathBuf>>()?,
        None => {
            let split = split_object(dirname)?;
            cache.set_item(dirname.clone(), split.clone())?;
            split
        }
    };

    let mut lo = lo.unwrap_or(0);
    let mut hi = hi;

    while lo < hi {
        let mid = (lo + hi) / 2;
        let dirblock = dirblocks.get_item(mid)?.cast_into::<PyTuple>()?;
        let cur = dirblock.get_item(0)?;

        let cur_split = match cache.get_item(&cur)? {
            Some(item) => item.extract::<Vec<PathBuf>>()?,
            None => {
                let split = split_object(&cur)?;
                cache.set_item(cur, split.clone())?;
                split
            }
        };

        if cur_split < dirname_split {
            lo = mid + 1;
        } else {
            hi = mid;
        }
    }
    Ok(lo)
}

/// Lightweight `os.stat_result`-shaped pyclass exposing exactly the
/// six fields dirstate consumes.
#[pyclass]
struct StatResult {
    info: bazaar::dirstate::StatInfo,
}

#[pymethods]
impl StatResult {
    #[getter]
    fn st_size(&self) -> u64 {
        self.info.size
    }

    #[getter]
    fn st_mtime(&self) -> i64 {
        self.info.mtime
    }

    #[getter]
    fn st_ctime(&self) -> i64 {
        self.info.ctime
    }

    #[getter]
    fn st_mode(&self) -> u32 {
        self.info.mode
    }

    #[getter]
    fn st_dev(&self) -> u64 {
        self.info.dev
    }

    #[getter]
    fn st_ino(&self) -> u64 {
        self.info.ino
    }
}

/// Abstract base for sha1 providers, ported from
/// `bzrformats.dirstate.SHA1Provider`. Concrete providers (and breezy's
/// `ContentFilterAwareSHA1Provider`) extend it; the abstract methods raise
/// `NotImplementedError` so a bare instance behaves like the old ABC.
#[pyclass(
    subclass,
    name = "SHA1Provider",
    module = "bzrformats._bzr_rs.dirstate"
)]
struct SHA1Provider;

#[pymethods]
impl SHA1Provider {
    #[new]
    #[pyo3(signature = (*args, **kwargs))]
    fn new(args: &Bound<'_, PyTuple>, kwargs: Option<&Bound<'_, PyDict>>) -> Self {
        // Accept and ignore any args, so Python subclasses (e.g. breezy's
        // ContentFilterAwareSHA1Provider, whose __init__ takes a tree) don't
        // trip the native base __new__.
        let _ = (args, kwargs);
        SHA1Provider
    }

    fn sha1(&self, abspath: Bound<'_, PyAny>) -> PyResult<()> {
        let _ = abspath;
        Err(pyo3::exceptions::PyNotImplementedError::new_err(
            "SHA1Provider.sha1",
        ))
    }

    fn stat_and_sha1(&self, abspath: Bound<'_, PyAny>) -> PyResult<()> {
        let _ = abspath;
        Err(pyo3::exceptions::PyNotImplementedError::new_err(
            "SHA1Provider.stat_and_sha1",
        ))
    }
}

/// The default, filesystem-backed sha1 provider. Extends [`SHA1Provider`].
#[pyclass(
    name = "DefaultSHA1Provider",
    extends = SHA1Provider,
    module = "bzrformats._bzr_rs.dirstate"
)]
struct PyDefaultSHA1Provider {
    provider: Box<dyn bazaar::dirstate::SHA1Provider + Send + Sync>,
}

#[pymethods]
impl PyDefaultSHA1Provider {
    #[new]
    fn new() -> PyClassInitializer<Self> {
        PyClassInitializer::from(SHA1Provider).add_subclass(PyDefaultSHA1Provider {
            provider: Box::new(bazaar::dirstate::DefaultSHA1Provider::new()),
        })
    }

    fn sha1<'a>(&mut self, py: Python<'a>, path: &Bound<PyAny>) -> PyResult<Bound<'a, PyBytes>> {
        let path = extract_path(path)?;
        let sha1 = self
            .provider
            .sha1(&path)
            .map_err(PyErr::new::<pyo3::exceptions::PyOSError, _>)?;
        Ok(PyBytes::new(py, sha1.as_bytes()))
    }

    fn stat_and_sha1<'a>(
        &mut self,
        py: Python<'a>,
        path: &Bound<PyAny>,
    ) -> PyResult<(Py<PyAny>, Bound<'a, PyBytes>)> {
        let path = extract_path(path)?;
        let (info, sha1) = self.provider.stat_and_sha1(&path)?;
        let pmd = StatResult { info };
        Ok((
            pmd.into_pyobject(py)?.unbind().into(),
            PyBytes::new(py, sha1.as_bytes()),
        ))
    }
}

/// Adapter that lets a Python `SHA1Provider`-shaped object (anything
/// with a `sha1(abspath)` method returning bytes) be plugged into the
/// pure-Rust `DirState`. The provider is held as a `Py<PyAny>` so we
/// can call back into Python; the GIL is acquired on each call.
struct PyCallbackSha1Provider {
    obj: Py<PyAny>,
}

impl bazaar::dirstate::SHA1Provider for PyCallbackSha1Provider {
    fn sha1(&self, path: &std::path::Path) -> std::io::Result<String> {
        Python::attach(|py| {
            let path_obj = path_to_py(py, path)
                .map_err(|e| std::io::Error::other(format!("path_to_py: {}", e)))?;
            let result = self
                .obj
                .bind(py)
                .call_method1("sha1", (path_obj,))
                .map_err(|e| std::io::Error::other(format!("sha1 callback: {}", e)))?;
            let bytes: &[u8] = result
                .extract()
                .map_err(|e| std::io::Error::other(format!("sha1 result: {}", e)))?;
            std::str::from_utf8(bytes)
                .map(|s| s.to_string())
                .map_err(|e| std::io::Error::other(format!("sha1 utf8: {}", e)))
        })
    }

    fn stat_and_sha1(
        &self,
        path: &std::path::Path,
    ) -> std::io::Result<(bazaar::dirstate::StatInfo, String)> {
        Python::attach(|py| {
            let path_obj = path_to_py(py, path)
                .map_err(|e| std::io::Error::other(format!("path_to_py: {}", e)))?;
            let result = self
                .obj
                .bind(py)
                .call_method1("stat_and_sha1", (path_obj,))
                .map_err(|e| std::io::Error::other(format!("stat_and_sha1 callback: {}", e)))?;
            let (stat_obj, sha_obj): (Bound<'_, PyAny>, Bound<'_, PyAny>) = result
                .extract()
                .map_err(|e| std::io::Error::other(format!("stat_and_sha1 result: {}", e)))?;
            let info = stat_result_to_info(&stat_obj)
                .map_err(|e| std::io::Error::other(format!("stat_and_sha1 stat_result: {}", e)))?;
            let sha_bytes: &[u8] = sha_obj
                .extract()
                .map_err(|e| std::io::Error::other(format!("stat_and_sha1 sha bytes: {}", e)))?;
            let sha = std::str::from_utf8(sha_bytes)
                .map_err(|e| std::io::Error::other(format!("stat_and_sha1 sha utf8: {}", e)))?
                .to_string();
            Ok((info, sha))
        })
    }
}

/// Read the `st_*` attributes off a Python `os.stat_result`-shaped
/// object and pack them into a [`StatInfo`].  Used by callback
/// adapters that bridge Python `SHA1Provider`s into the Rust trait.
fn stat_result_to_info(obj: &Bound<'_, PyAny>) -> PyResult<bazaar::dirstate::StatInfo> {
    Ok(bazaar::dirstate::StatInfo {
        mode: obj.getattr("st_mode")?.extract()?,
        size: obj.getattr("st_size")?.extract()?,
        mtime: obj.getattr("st_mtime")?.extract::<f64>()? as i64,
        ctime: obj.getattr("st_ctime")?.extract::<f64>()? as i64,
        dev: obj.getattr("st_dev")?.extract()?,
        ino: obj.getattr("st_ino")?.extract()?,
    })
}

/// Build the `Box<dyn SHA1Provider>` to hand to `DirState::new`.
/// Recognises the pyo3 `SHA1Provider` pyclass (uses its inner Rust
/// provider directly) and otherwise wraps the Python object in
/// `PyCallbackSha1Provider`.
fn sha1_provider_from_py(
    py: Python<'_>,
    obj: &Bound<PyAny>,
) -> Box<dyn bazaar::dirstate::SHA1Provider + Send + Sync> {
    let _ = py;
    Box::new(PyCallbackSha1Provider {
        obj: obj.clone().unbind(),
    })
}

/// Convert a `&Path` to a Python object suitable for passing to
/// `SHA1Provider.sha1`. On Unix, hand back raw bytes so non-utf8
/// paths survive; on other platforms fall back to the path string.
fn path_to_py<'py>(py: Python<'py>, path: &Path) -> PyResult<Bound<'py, PyAny>> {
    #[cfg(unix)]
    {
        use std::os::unix::ffi::OsStrExt;
        let bytes = path.as_os_str().as_bytes();
        Ok(PyBytes::new(py, bytes).into_any())
    }
    #[cfg(not(unix))]
    {
        let s = path.to_string_lossy();
        Ok(PyString::new(py, &s).into_any())
    }
}

/// Python constants that [`DirStateRs`] uses in its scalar-state
/// getters/setters to match `bzrformats.dirstate.DirState`'s
/// `NOT_IN_MEMORY` / `IN_MEMORY_UNMODIFIED` / `IN_MEMORY_MODIFIED` /
/// `IN_MEMORY_HASH_MODIFIED` class attributes.
const PY_NOT_IN_MEMORY: i64 = 0;
const PY_IN_MEMORY_UNMODIFIED: i64 = 1;
const PY_IN_MEMORY_MODIFIED: i64 = 2;
const PY_IN_MEMORY_HASH_MODIFIED: i64 = 3;

/// Build the Python tuple representation of a single dirstate entry,
/// matching the shape `((dirname, basename, file_id),
/// [(minikind, fingerprint, size, executable, packed_stat), ...])`
/// that `DirStateRs.dirblocks` and the rest of the legacy Python
/// `_dirblocks` consumers use.
fn entry_to_py_tuple<'py>(
    py: Python<'py>,
    entry: &bazaar::dirstate::Entry,
) -> PyResult<Bound<'py, PyTuple>> {
    let key = PyTuple::new(
        py,
        [
            PyBytes::new(py, &entry.key.dirname).into_any(),
            PyBytes::new(py, &entry.key.basename).into_any(),
            PyBytes::new(py, &entry.key.file_id).into_any(),
        ],
    )?;
    let trees = PyList::empty(py);
    for tree in &entry.trees {
        let tree_tuple = PyTuple::new(
            py,
            [
                PyBytes::new(py, &[tree.minikind.to_minikind()]).into_any(),
                PyBytes::new(py, &tree.fingerprint).into_any(),
                tree.size.into_pyobject(py)?.into_any(),
                tree.executable.into_pyobject(py)?.to_owned().into_any(),
                PyBytes::new(py, &tree.packed_stat).into_any(),
            ],
        )?;
        trees.append(tree_tuple)?;
    }
    PyTuple::new(py, [key.as_any(), trees.as_any()])
}

/// Collect any Python iterable of `bytes` into a `Vec<Vec<u8>>`. Used
/// by the parents / ghosts setters on [`PyDirState`] to accept plain
/// Python lists as well as tuples or generators.
fn collect_bytes_vec(obj: &Bound<PyAny>) -> PyResult<Vec<Vec<u8>>> {
    let mut out = Vec::new();
    for item in obj.try_iter()? {
        out.push(item?.extract::<Vec<u8>>()?);
    }
    Ok(out)
}

/// Collect a Python `set[bytes]` (or any iterable of bytes) into a
/// `HashSet<Vec<u8>>`.
fn collect_bytes_set(obj: &Bound<PyAny>) -> PyResult<std::collections::HashSet<Vec<u8>>> {
    let mut out = std::collections::HashSet::new();
    for item in obj.try_iter()? {
        out.insert(item?.extract::<Vec<u8>>()?);
    }
    Ok(out)
}

/// Collect a Python `dict[bytes, bytes]` into a `HashMap<Vec<u8>, Vec<u8>>`.
fn collect_bytes_map(d: &Bound<PyDict>) -> PyResult<std::collections::HashMap<Vec<u8>, Vec<u8>>> {
    let mut out = std::collections::HashMap::new();
    for (k, v) in d.iter() {
        out.insert(k.extract::<Vec<u8>>()?, v.extract::<Vec<u8>>()?);
    }
    Ok(out)
}

/// Decode a Python `[dirname_or_none, file_id_or_none]` list into the
/// `Option<(Vec<u8>, Option<Vec<u8>>)>` shape
/// [`bazaar::dirstate::ProcessEntryState::last_source_parent`] uses.
fn decode_last_parent(lst: &Bound<PyList>) -> PyResult<Option<(Vec<u8>, Option<Vec<u8>>)>> {
    if lst.len() < 2 {
        return Ok(None);
    }
    let d = lst.get_item(0)?;
    if d.is_none() {
        return Ok(None);
    }
    let dirname: Vec<u8> = d.extract()?;
    let f = lst.get_item(1)?;
    let file_id: Option<Vec<u8>> = if f.is_none() {
        None
    } else {
        Some(f.extract()?)
    };
    Ok(Some((dirname, file_id)))
}

/// Replace the contents of `target` with `source` — used when the
/// pure-crate process_entry added new entries to its `search_specific_files`
/// set that Python's ProcessEntryPython.search_specific_files needs to
/// see.
fn write_back_bytes_set(
    target: &Bound<PyAny>,
    source: &std::collections::HashSet<Vec<u8>>,
) -> PyResult<()> {
    target.call_method0("clear")?;
    for item in source {
        target.call_method1("add", (PyBytes::new(target.py(), item),))?;
    }
    Ok(())
}

fn write_back_bytes_map(
    target: &Bound<PyDict>,
    source: &std::collections::HashMap<Vec<u8>, Vec<u8>>,
) -> PyResult<()> {
    target.clear();
    let py = target.py();
    for (k, v) in source {
        target.set_item(PyBytes::new(py, k), PyBytes::new(py, v))?;
    }
    Ok(())
}

fn write_back_last_parent(
    target: &Bound<PyList>,
    source: &Option<(Vec<u8>, Option<Vec<u8>>)>,
) -> PyResult<()> {
    let py = target.py();
    while target.len() < 2 {
        target.append(py.None())?;
    }
    match source {
        Some((dn, fid)) => {
            target.set_item(0, PyBytes::new(py, dn))?;
            target.set_item(
                1,
                match fid {
                    Some(b) => PyBytes::new(py, b).into_any(),
                    None => py.None().into_bound(py),
                },
            )?;
        }
        None => {
            target.set_item(0, py.None())?;
            target.set_item(1, py.None())?;
        }
    }
    Ok(())
}

/// Convert a Rust [`bazaar::dirstate::DirstateChange`] into the 9-tuple
/// Python's `DirstateInventoryChange` constructor accepts, with path
/// fields utf8-decoded using `surrogateescape`.
fn dirstate_change_to_pytuple<'py>(
    py: Python<'py>,
    change: &bazaar::dirstate::DirstateChange,
) -> PyResult<Bound<'py, PyTuple>> {
    fn decode_bytes<'py>(py: Python<'py>, b: &Option<Vec<u8>>) -> PyResult<Py<PyAny>> {
        match b {
            None => Ok(py.None()),
            Some(v) => {
                // utf8 decode with surrogateescape, matching
                // self.utf8_decode(..., "surrogateescape") in Python.
                let py_bytes = PyBytes::new(py, v);
                let s = py_bytes
                    .call_method1("decode", ("utf-8", "surrogateescape"))?
                    .unbind();
                Ok(s)
            }
        }
    }

    let path_tuple = PyTuple::new(
        py,
        [
            decode_bytes(py, &change.old_path)?,
            decode_bytes(py, &change.new_path)?,
        ],
    )?;
    let versioned_tuple = PyTuple::new(
        py,
        [
            pyo3::types::PyBool::new(py, change.old_versioned)
                .to_owned()
                .into_any(),
            pyo3::types::PyBool::new(py, change.new_versioned)
                .to_owned()
                .into_any(),
        ],
    )?;
    let parent_tuple = PyTuple::new(
        py,
        [
            match &change.source_parent_id {
                Some(v) => PyBytes::new(py, v).into_any().unbind(),
                None => py.None(),
            },
            match &change.target_parent_id {
                Some(v) => PyBytes::new(py, v).into_any().unbind(),
                None => py.None(),
            },
        ],
    )?;
    let name_tuple = PyTuple::new(
        py,
        [
            decode_bytes(py, &change.old_basename)?,
            decode_bytes(py, &change.new_basename)?,
        ],
    )?;
    let kind_tuple = PyTuple::new(
        py,
        [
            match change.source_kind {
                Some(k) => PyString::new(py, k.as_str()).into_any().unbind(),
                None => py.None(),
            },
            match change.target_kind {
                Some(k) => PyString::new(py, k.as_str()).into_any().unbind(),
                None => py.None(),
            },
        ],
    )?;
    let exec_tuple = PyTuple::new(
        py,
        [
            match change.source_exec {
                Some(b) => pyo3::types::PyBool::new(py, b)
                    .to_owned()
                    .into_any()
                    .unbind(),
                None => py.None(),
            },
            match change.target_exec {
                Some(b) => pyo3::types::PyBool::new(py, b)
                    .to_owned()
                    .into_any()
                    .unbind(),
                None => py.None(),
            },
        ],
    )?;
    // Python expects file_id=None for unversioned entries.  The Rust
    // DirstateChange currently stores file_id as Vec<u8>, with an
    // empty vec sentinel meaning "unversioned"; surface that as None
    // here so the resulting InventoryTreeChange compares equal to
    // what InterInventoryTree.iter_changes produces.
    let file_id_obj = if change.file_id.is_empty() && !change.old_versioned && !change.new_versioned
    {
        py.None()
    } else {
        PyBytes::new(py, &change.file_id).into_any().unbind()
    };
    // `DirstateInventoryChange` carries nine slots (last is `copied`).
    // Dirstate doesn't model copies, so always surface `False`.
    PyTuple::new(
        py,
        [
            file_id_obj,
            path_tuple.into_any().unbind(),
            pyo3::types::PyBool::new(py, change.content_change)
                .to_owned()
                .into_any()
                .unbind(),
            versioned_tuple.into_any().unbind(),
            parent_tuple.into_any().unbind(),
            name_tuple.into_any().unbind(),
            kind_tuple.into_any().unbind(),
            exec_tuple.into_any().unbind(),
            pyo3::types::PyBool::new(py, false)
                .to_owned()
                .into_any()
                .unbind(),
        ],
    )
}

/// Convert an [`AddError`] to the Python exception that
/// `DirState.add` would have raised.
fn add_error_to_py(py: Python<'_>, err: bazaar::dirstate::AddError) -> PyErr {
    use bazaar::dirstate::AddError;
    match err {
        AddError::DuplicateFileId { file_id, info } => {
            DuplicateFileId::new_err((PyBytes::new(py, &file_id).unbind(), info))
        }
        AddError::AlreadyAdded { path } => {
            pyo3::exceptions::PyException::new_err(format!("adding already added path! {:?}", path))
        }
        AddError::NotVersioned { path } => {
            NotVersionedError::new_err((PyBytes::new(py, &path).unbind(), ""))
        }
        AddError::AlreadyAddedAssertion { basename, file_id } => {
            pyo3::exceptions::PyAssertionError::new_err(format!(
                " {:?}({:?}) already added",
                basename, file_id
            ))
        }
        AddError::Internal { reason } => pyo3::exceptions::PyAssertionError::new_err(reason),
        AddError::InvalidNormalization { path } => InvalidNormalization::new_err((path,)),
        AddError::InvalidEntryName { name } => InvalidEntryName::new_err((name,)),
    }
}

fn memory_state_to_py(state: bazaar::dirstate::MemoryState) -> i64 {
    use bazaar::dirstate::MemoryState;
    match state {
        MemoryState::NotInMemory => PY_NOT_IN_MEMORY,
        MemoryState::InMemoryUnmodified => PY_IN_MEMORY_UNMODIFIED,
        MemoryState::InMemoryModified => PY_IN_MEMORY_MODIFIED,
        MemoryState::InMemoryHashModified => PY_IN_MEMORY_HASH_MODIFIED,
    }
}

fn memory_state_from_py(value: i64) -> PyResult<bazaar::dirstate::MemoryState> {
    use bazaar::dirstate::MemoryState;
    match value {
        PY_NOT_IN_MEMORY => Ok(MemoryState::NotInMemory),
        PY_IN_MEMORY_UNMODIFIED => Ok(MemoryState::InMemoryUnmodified),
        PY_IN_MEMORY_MODIFIED => Ok(MemoryState::InMemoryModified),
        PY_IN_MEMORY_HASH_MODIFIED => Ok(MemoryState::InMemoryHashModified),
        other => Err(pyo3::exceptions::PyValueError::new_err(format!(
            "invalid memory state: {}",
            other
        ))),
    }
}

/// Python-facing owner of a pure-Rust [`bazaar::dirstate::DirState`].
///
/// This is the beginning of the gradual replacement of
/// `bzrformats.dirstate.DirState` with the Rust port: each commit
/// exposes a few more attributes or methods, Python's `DirState`
/// gradually delegates to them, and once the whole surface is here
/// the Python class collapses into a thin shim.
///
/// Commit 1 (this one) only exposes the scalar state flags and the
/// methods from the pure crate that do not touch dirblocks/parents
/// (`worth_saving`, `wipe_state`, `mark_modified`, `mark_unmodified`,
/// `num_present_parents`). Dirblocks, parents, ghosts, id_index, the
/// save path, and the various get_entry/iter variants come in later
/// commits.
#[pyclass(name = "DirState", subclass, dict)]
struct PyDirState {
    inner: bazaar::dirstate::DirState,
    /// Cached `IdIndex` pyclass instance held across calls. Lazily
    /// created on the first `_get_id_index` call and refreshed in
    /// place by `refresh_cached_id_index` after every mutation so
    /// callers that hold the returned object see fresh state.
    id_index: std::sync::Mutex<Option<Py<IdIndex>>>,
    /// Transport representing the dirstate file on disk. Created at
    /// construction time as a [`FileTransport`] pointing at the
    /// dirstate path; its lifetime matches this `DirState`.
    /// Lock acquisition/release happens *on* the transport via
    /// `lock_read` / `lock_write` / `unlock`; the transport itself
    /// is always present.
    transport: std::sync::Mutex<Box<dyn bazaar::dirstate::Transport + Send>>,
    /// Mini-bisect cache: index of the last entry returned by
    /// [`_find_entry_index`]. On the next call the cache lets us check
    /// `block[last + 1]` directly before falling back to a full
    /// `bisect_left`. Mirrors Python's `_last_entry_index` field.
    last_entry_index: std::sync::Mutex<Option<usize>>,
}

#[pymethods]
impl PyDirState {
    // Class-level constants. Mirror the `DirState.FOO` attributes the
    // Python class used to carry — tests and external callers still
    // reach for them as `DirState.NULLSTAT` etc.

    #[classattr]
    const BISECT_PAGE_SIZE: usize = 4096;

    #[classattr]
    const NOT_IN_MEMORY: i32 = 0;
    #[classattr]
    const IN_MEMORY_UNMODIFIED: i32 = 1;
    #[classattr]
    const IN_MEMORY_MODIFIED: i32 = 2;
    #[classattr]
    const IN_MEMORY_HASH_MODIFIED: i32 = 3;

    #[classattr]
    fn NULLSTAT(py: Python<'_>) -> Bound<'_, PyBytes> {
        // 32 'x' bytes — sentinel pack_stat value that never matches
        // a real base64-encoded stat.
        PyBytes::new(py, &[b'x'; 32])
    }

    #[classattr]
    fn NULL_PARENT_DETAILS(py: Python<'_>) -> PyResult<Bound<'_, PyTuple>> {
        PyTuple::new(
            py,
            [
                PyBytes::new(py, b"a").into_any(),
                PyBytes::new(py, b"").into_any(),
                0i64.into_pyobject(py)?.into_any(),
                pyo3::types::PyBool::new(py, false).to_owned().into_any(),
                PyBytes::new(py, b"").into_any(),
            ],
        )
    }

    #[classattr]
    fn HEADER_FORMAT_2(py: Python<'_>) -> Bound<'_, PyBytes> {
        PyBytes::new(py, b"#bazaar dirstate flat format 2\n")
    }

    #[classattr]
    fn HEADER_FORMAT_3(py: Python<'_>) -> Bound<'_, PyBytes> {
        PyBytes::new(py, b"#bazaar dirstate flat format 3\n")
    }

    /// Map from full kind name (str) to minikind byte.
    #[classattr]
    fn _kind_to_minikind(py: Python<'_>) -> PyResult<Bound<'_, pyo3::types::PyDict>> {
        let d = pyo3::types::PyDict::new(py);
        d.set_item("absent", PyBytes::new(py, b"a"))?;
        d.set_item("file", PyBytes::new(py, b"f"))?;
        d.set_item("directory", PyBytes::new(py, b"d"))?;
        d.set_item("relocated", PyBytes::new(py, b"r"))?;
        d.set_item("symlink", PyBytes::new(py, b"l"))?;
        d.set_item("tree-reference", PyBytes::new(py, b"t"))?;
        Ok(d)
    }

    /// Map from minikind byte to full kind name (str). Used by
    /// breezy's workingtree_4 to translate dirstate entries.
    #[classattr]
    fn _minikind_to_kind(py: Python<'_>) -> PyResult<Bound<'_, pyo3::types::PyDict>> {
        let d = pyo3::types::PyDict::new(py);
        d.set_item(PyBytes::new(py, b"a"), "absent")?;
        d.set_item(PyBytes::new(py, b"f"), "file")?;
        d.set_item(PyBytes::new(py, b"d"), "directory")?;
        d.set_item(PyBytes::new(py, b"l"), "symlink")?;
        d.set_item(PyBytes::new(py, b"r"), "relocated")?;
        d.set_item(PyBytes::new(py, b"t"), "tree-reference")?;
        Ok(d)
    }

    /// Map from `stat.S_IF*` constants to the corresponding minikind
    /// byte.
    #[classattr]
    fn _stat_to_minikind(py: Python<'_>) -> PyResult<Bound<'_, pyo3::types::PyDict>> {
        // The S_IF* file-type bits are fixed POSIX values (the same ones
        // Python's `stat` module exposes), so use them directly rather
        // than importing `stat`.
        const S_IFDIR: u32 = 0o040000;
        const S_IFREG: u32 = 0o100000;
        const S_IFLNK: u32 = 0o120000;
        let d = pyo3::types::PyDict::new(py);
        d.set_item(S_IFDIR, PyBytes::new(py, b"d"))?;
        d.set_item(S_IFREG, PyBytes::new(py, b"f"))?;
        d.set_item(S_IFLNK, PyBytes::new(py, b"l"))?;
        Ok(d)
    }

    /// Boolean-to-byte mapping used when serialising the executable
    /// flag.
    #[classattr]
    fn _to_yesno(py: Python<'_>) -> PyResult<Bound<'_, pyo3::types::PyDict>> {
        let d = pyo3::types::PyDict::new(py);
        d.set_item(true, PyBytes::new(py, b"y"))?;
        d.set_item(false, PyBytes::new(py, b"n"))?;
        Ok(d)
    }

    /// Construct an empty dirstate at `path`. Mirrors Python's
    /// `DirState.__init__` for the pure-state fields only — lock and
    /// file-object plumbing stays on the Python side until its
    /// counterpart exists in Rust.
    #[new]
    #[pyo3(signature = (
        path,
        sha1_provider = None,
        worth_saving_limit = 0,
        use_filesystem_for_exec = true,
        fdatasync = false,
    ))]
    fn new(
        py: Python<'_>,
        path: &Bound<PyAny>,
        sha1_provider: Option<&Bound<PyAny>>,
        worth_saving_limit: i64,
        use_filesystem_for_exec: bool,
        fdatasync: bool,
    ) -> PyResult<Self> {
        let path = extract_path(path)?;
        let provider: Box<dyn bazaar::dirstate::SHA1Provider + Send + Sync> = match sha1_provider {
            Some(obj) => sha1_provider_from_py(py, obj),
            None => Box::new(bazaar::dirstate::DefaultSHA1Provider::new()),
        };
        let transport: Box<dyn bazaar::dirstate::Transport + Send> =
            Box::new(bazaar::dirstate::FileTransport::new(path.clone()));
        Ok(Self {
            inner: bazaar::dirstate::DirState::new(
                path,
                provider,
                worth_saving_limit,
                use_filesystem_for_exec,
                fdatasync,
            ),
            id_index: std::sync::Mutex::new(None),
            transport: std::sync::Mutex::new(transport),
            last_entry_index: std::sync::Mutex::new(None),
        })
    }

    /// On-disk filename the dirstate points at. Read-only; matches
    /// Python's `DirState._filename` attribute, which is the ``str``
    /// path returned by ``Transport.local_abspath`` (always str, on
    /// every platform).
    #[getter(_filename)]
    fn filename<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, pyo3::types::PyString>> {
        let s = self
            .inner
            .filename
            .to_str()
            .ok_or_else(|| PyTypeError::new_err("dirstate filename is not valid utf-8"))?;
        Ok(pyo3::types::PyString::new(py, s))
    }

    /// Header state flag matching Python's `_header_state` attribute.
    #[getter(_header_state)]
    fn header_state(&self) -> i64 {
        memory_state_to_py(self.inner.header_state)
    }

    #[setter(_header_state)]
    fn set_header_state(&mut self, value: i64) -> PyResult<()> {
        self.inner.header_state = memory_state_from_py(value)?;
        Ok(())
    }

    /// Dirblock state flag matching Python's `_dirblock_state`.
    #[getter(_dirblock_state)]
    fn dirblock_state(&self) -> i64 {
        memory_state_to_py(self.inner.dirblock_state)
    }

    #[setter(_dirblock_state)]
    fn set_dirblock_state(&mut self, value: i64) -> PyResult<()> {
        self.inner.dirblock_state = memory_state_from_py(value)?;
        Ok(())
    }

    #[getter(_changes_aborted)]
    fn changes_aborted(&self) -> bool {
        self.inner.changes_aborted
    }

    #[setter(_changes_aborted)]
    fn set_changes_aborted(&mut self, value: bool) {
        self.inner.changes_aborted = value;
    }

    /// Offset in the backing file where the header ends and the
    /// dirblock body begins. `None` before the header has been read.
    /// Matches Python's `_end_of_header` attribute.
    #[getter(_end_of_header)]
    fn end_of_header(&self) -> Option<u64> {
        self.inner.end_of_header
    }

    #[setter(_end_of_header)]
    fn set_end_of_header(&mut self, value: Option<u64>) {
        self.inner.end_of_header = value;
    }

    /// Cutoff mtime/ctime used when deciding whether cached sha1s are
    /// trustworthy. `None` before `_sha_cutoff_time` runs. Matches
    /// Python's `_cutoff_time` attribute.
    #[getter(_cutoff_time)]
    fn cutoff_time(&self) -> Option<i64> {
        self.inner.cutoff_time
    }

    #[setter(_cutoff_time)]
    fn set_cutoff_time(&mut self, value: Option<i64>) {
        self.inner.cutoff_time = value;
    }

    /// Compute, cache, and return the SHA cutoff time (`now - 3`).
    /// Mirrors Python's `DirState._sha_cutoff_time`.
    #[pyo3(name = "_sha_cutoff_time")]
    fn compute_sha_cutoff_time(&mut self) -> i64 {
        self.inner.compute_sha_cutoff_time()
    }

    /// Declared entry count from the header. Matches Python's
    /// `_num_entries`; Python stores `None` before the header is read,
    /// but the Rust struct always has a count, so we expose the
    /// numeric value unconditionally.
    #[getter]
    fn num_entries(&self) -> usize {
        self.inner.num_entries
    }

    #[setter]
    fn set_num_entries(&mut self, value: usize) {
        self.inner.num_entries = value;
    }

    #[getter(_worth_saving_limit)]
    fn worth_saving_limit(&self) -> i64 {
        self.inner.worth_saving_limit
    }

    #[setter(_worth_saving_limit)]
    fn set_worth_saving_limit(&mut self, value: i64) {
        self.inner.worth_saving_limit = value;
    }

    #[getter(_fdatasync)]
    fn fdatasync(&self) -> bool {
        self.inner.fdatasync
    }

    #[setter(_fdatasync)]
    fn set_fdatasync(&mut self, value: bool) {
        self.inner.fdatasync = value;
    }

    #[getter(_use_filesystem_for_exec)]
    fn use_filesystem_for_exec(&self) -> bool {
        self.inner.use_filesystem_for_exec
    }

    #[setter(_use_filesystem_for_exec)]
    fn set_use_filesystem_for_exec(&mut self, value: bool) {
        self.inner.use_filesystem_for_exec = value;
    }

    #[getter(_bisect_page_size)]
    fn bisect_page_size(&self) -> usize {
        self.inner.bisect_page_size
    }

    #[setter(_bisect_page_size)]
    fn set_bisect_page_size(&mut self, value: usize) {
        self.inner.bisect_page_size = value;
    }

    /// Number of parent entries present in each record row. Mirrors
    /// Python's `DirState._num_present_parents`.
    #[pyo3(name = "_num_present_parents")]
    fn num_present_parents(&self) -> usize {
        self.inner.num_present_parents()
    }

    /// Parent revision ids for the current tree, in order. First
    /// entry is the current parent; subsequent entries are merged
    /// parents. Matches Python's `DirState._parents` attribute.
    ///
    /// Returns a fresh Python list on each access — mutating that
    /// list does NOT write back to the dirstate. Use
    /// [`Self::append_parent`] or [`Self::set_parent_at`] for in-place
    /// mutation, or assign the attribute to replace the list
    /// wholesale.
    #[getter(_parents)]
    fn parents<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyList>> {
        let items: Vec<Bound<PyBytes>> = self
            .inner
            .parents
            .iter()
            .map(|p| PyBytes::new(py, p))
            .collect();
        PyList::new(py, items)
    }

    #[setter(_parents)]
    fn set_parents(&mut self, value: &Bound<PyAny>) -> PyResult<()> {
        self.inner.parents = collect_bytes_vec(value)?;
        Ok(())
    }

    /// Ghost parent revision ids: parents referenced by the tree but
    /// not present locally. Same aliasing semantics as
    /// [`Self::parents`] — the getter returns a copy.
    #[getter(_ghosts)]
    fn _ghosts_attr<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyList>> {
        let items: Vec<Bound<PyBytes>> = self
            .inner
            .ghosts
            .iter()
            .map(|g| PyBytes::new(py, g))
            .collect();
        PyList::new(py, items)
    }

    #[setter(_ghosts)]
    fn _ghosts_attr_setter(&mut self, value: &Bound<PyAny>) -> PyResult<()> {
        self.inner.ghosts = collect_bytes_vec(value)?;
        Ok(())
    }

    /// Append a revision id to the parents list in place. Replaces
    /// the Python pattern `self._parents.append(revid)`.
    fn append_parent(&mut self, revid: Vec<u8>) {
        self.inner.parents.push(revid);
    }

    /// In-memory dirblocks, in the same list-of-tuples shape Python's
    /// `DirState._dirblocks` uses. Each block is `(dirname_bytes,
    /// [entry_tuple, ...])`; each entry is
    /// `((dirname, basename, file_id), [tree_tuple, ...])`; each tree
    /// tuple is `(minikind, fingerprint, size, executable,
    /// packed_stat_or_revid)`.
    ///
    /// Both the getter and the setter convert the full dirblock tree
    /// on every call. They exist as a temporary sync boundary while
    /// dirblock ownership migrates from Python's `_dirblocks`
    /// attribute to the pure-Rust `DirState.dirblocks` field. Once
    /// every reader and writer on the Python side has migrated, these
    /// conversions go away along with Python's `_dirblocks`.
    ///
    /// Writing through the setter clears the cached id_index, since
    /// the previous index is no longer consistent with the new data.
    #[getter(_dirblocks)]
    fn dirblocks<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyList>> {
        crate::dirstate_helpers::dirblocks_to_py(py, &self.inner.dirblocks)
    }

    #[setter(_dirblocks)]
    fn set_dirblocks(&mut self, value: &Bound<PyAny>) -> PyResult<()> {
        let new_blocks = crate::dirstate_helpers::dirblocks_from_py(value)?;
        self.inner.dirblocks = new_blocks;
        self.inner.id_index = None;
        self.inner.packed_stat_index = None;
        Ok(())
    }

    /// Replace the parent at `index`. Replaces the Python pattern
    /// `self._parents[index] = revid`. Raises `IndexError` if `index`
    /// is out of range.
    fn set_parent_at(&mut self, index: usize, revid: Vec<u8>) -> PyResult<()> {
        if index >= self.inner.parents.len() {
            return Err(pyo3::exceptions::PyIndexError::new_err(
                "parent index out of range",
            ));
        }
        self.inner.parents[index] = revid;
        Ok(())
    }

    /// Whether the current in-memory state is worth persisting. Mirrors
    /// `DirState._worth_saving`.
    #[pyo3(name = "_worth_saving")]
    fn worth_saving(&self) -> bool {
        self.inner.worth_saving()
    }

    /// Record the observed sha1 for the entry at `key` and return the
    /// updated tree-0 5-tuple (or `None` if no update was recorded —
    /// non-regular file, or uncacheable mtime/ctime).  Mirrors
    /// Python's `DirState._observed_sha1` including the write-back
    /// the Python side used to do with a second `get_entry` call.
    ///
    /// Accepts the full dirstate entry tuple `(key, tree_states)` and
    /// the stat-like object; pulls the key, reads `st_mode`/`st_size`/
    /// `st_mtime`/`st_ctime` (with `getattr(..., 'st_dev', 0)` and
    /// `'st_ino'` fallbacks to support `breezy.filters.FilteredStat`-
    /// shaped stand-ins), and on success mutates `entry[1][0]` in place
    /// with the new tree-0 details. No-op when the entry is the
    /// `(None, None)` unversioned-path sentinel.
    #[pyo3(name = "_observed_sha1")]
    fn observed_sha1(
        &mut self,
        py: Python<'_>,
        entry: &Bound<PyAny>,
        sha1: &[u8],
        stat_value: &Bound<PyAny>,
    ) -> PyResult<()> {
        let entry_tuple = entry.cast::<PyTuple>()?;
        if entry_tuple.get_item(0)?.is_none() {
            return Ok(());
        }
        let key = entry_tuple.get_item(0)?.cast_into::<PyTuple>()?;
        let entry_key = bazaar::dirstate::EntryKey {
            dirname: key.get_item(0)?.extract()?,
            basename: key.get_item(1)?.extract()?,
            file_id: key.get_item(2)?.extract()?,
        };
        let st_mode: u32 = stat_value.getattr("st_mode")?.extract()?;
        let st_size: u64 = stat_value.getattr("st_size")?.extract()?;
        let st_mtime: f64 = stat_value.getattr("st_mtime")?.extract()?;
        let st_ctime: f64 = stat_value.getattr("st_ctime")?.extract()?;
        // FilteredStat doesn't carry st_dev/st_ino; fall back to 0.
        let st_dev: u64 = stat_value
            .getattr("st_dev")
            .and_then(|v| v.extract())
            .unwrap_or(0);
        let st_ino: u64 = stat_value
            .getattr("st_ino")
            .and_then(|v| v.extract())
            .unwrap_or(0);
        let updated = self
            .inner
            .observed_sha1(
                &entry_key,
                sha1,
                st_mode,
                st_size,
                st_mtime as i64,
                st_ctime as i64,
                st_dev,
                st_ino,
            )
            .map_err(|e| match e {
                bazaar::dirstate::UpdateEntryError::EntryNotFound => {
                    pyo3::exceptions::PyKeyError::new_err("observed_sha1: entry not found")
                }
                bazaar::dirstate::UpdateEntryError::Io(io) => {
                    pyo3::exceptions::PyOSError::new_err(io.to_string())
                }
                other => pyo3::exceptions::PyRuntimeError::new_err(other.to_string()),
            })?;
        if let Some(td) = updated {
            // Mutate entry[1][0] in place so the caller's reference
            // reflects the new tree-0 details.
            let new_tree0 = PyTuple::new(
                py,
                [
                    PyBytes::new(py, &[td.minikind.to_minikind()]).into_any(),
                    PyBytes::new(py, &td.fingerprint).into_any(),
                    td.size.into_pyobject(py)?.into_any(),
                    pyo3::types::PyBool::new(py, td.executable)
                        .to_owned()
                        .into_any(),
                    PyBytes::new(py, &td.packed_stat).into_any(),
                ],
            )?;
            let trees = entry_tuple.get_item(1)?;
            trees.set_item(0, new_tree0)?;
        }
        Ok(())
    }

    /// Refresh the tree-0 slot of the entry at `key` from the
    /// filesystem.  Mirrors Python's `py_update_entry`.
    /// `abspath` is the absolute path of the file on disk.
    /// `stat_value` is a Python `os.stat_result` (usually produced by
    /// `os.lstat`).  Returns the sha1 hex or symlink target as
    /// `bytes`, or `None` when the cache matches / the on-disk kind
    /// is unsupported / the stat falls in the uncacheable window.
    fn update_entry<'py>(
        &mut self,
        py: Python<'py>,
        key: &Bound<PyTuple>,
        abspath: &Bound<PyAny>,
        stat_value: &Bound<PyAny>,
    ) -> PyResult<Option<Bound<'py, PyBytes>>> {
        let entry_key = bazaar::dirstate::EntryKey {
            dirname: key.get_item(0)?.extract()?,
            basename: key.get_item(1)?.extract()?,
            file_id: key.get_item(2)?.extract()?,
        };
        let abspath_bytes: Vec<u8> = abspath.extract()?;
        // Unpack stat_value into a StatInfo — Python already did the
        // lstat, no need to double-stat.
        let stat = bazaar::dirstate::StatInfo {
            mode: stat_value.getattr("st_mode")?.extract()?,
            size: stat_value.getattr("st_size")?.extract()?,
            mtime: stat_value.getattr("st_mtime")?.extract::<f64>()? as i64,
            ctime: stat_value.getattr("st_ctime")?.extract::<f64>()? as i64,
            dev: stat_value.getattr("st_dev")?.extract()?,
            ino: stat_value.getattr("st_ino")?.extract()?,
        };
        // Transport for read_link / lstat; `update_entry` only uses
        // read_link (symlink case), but we still need the trait object.
        // A fresh PyFileTransport over a placeholder file is wrong —
        // PyFileTransport's read_all/write_all/fdatasync are tied to
        // the dirstate file.  The pure-crate contract is that lstat
        // and read_link take arbitrary paths, which PyFileTransport
        // implements via os.lstat / os.readlink directly.
        let transport = PyFileTransport::new(
            pyo3::types::PyNone::get(py).to_owned().into_any().unbind(),
            bazaar::dirstate::LockState::Read,
        );
        let result = self
            .inner
            .update_entry(&entry_key, &abspath_bytes, &stat, &transport)
            .map_err(|e| match e {
                bazaar::dirstate::UpdateEntryError::EntryNotFound => {
                    pyo3::exceptions::PyKeyError::new_err("update_entry: entry not found")
                }
                bazaar::dirstate::UpdateEntryError::Io(io) => {
                    pyo3::exceptions::PyOSError::new_err(io.to_string())
                }
                other => pyo3::exceptions::PyRuntimeError::new_err(other.to_string()),
            })?;
        Ok(result.map(|v| PyBytes::new(py, &v)))
    }

    /// Compare one dirstate entry against what's on disk (or against
    /// "absent on disk" when `path_info` is None).  Mirrors Python's
    /// `ProcessEntryPython._process_entry`.
    ///
    /// Returns `(change_tuple | None, changed_or_None)`.  The change
    /// tuple is the 9-field record Python's `DirstateInventoryChange`
    /// constructor takes (with utf8 path fields already decoded on the
    /// Rust side using surrogateescape).
    ///
    /// Caller-owned state dict/sets (passed in and mutated in place):
    /// `searched_specific_files`, `search_specific_files`,
    /// `old_dirname_to_file_id`, `new_dirname_to_file_id`,
    /// `last_source_parent`, `last_target_parent`.
    #[pyo3(signature = (
        entry,
        path_info,
        source_index,
        target_index,
        include_unchanged,
        searched_specific_files,
        search_specific_files,
        old_dirname_to_file_id,
        new_dirname_to_file_id,
        last_source_parent,
        last_target_parent,
    ))]
    #[allow(clippy::too_many_arguments)]
    fn process_entry<'py>(
        &mut self,
        py: Python<'py>,
        entry: &Bound<PyAny>,
        path_info: Option<&Bound<PyAny>>,
        source_index: Option<usize>,
        target_index: usize,
        include_unchanged: bool,
        searched_specific_files: &Bound<PyAny>,
        search_specific_files: &Bound<PyAny>,
        old_dirname_to_file_id: &Bound<PyDict>,
        new_dirname_to_file_id: &Bound<PyDict>,
        last_source_parent: &Bound<PyList>,
        last_target_parent: &Bound<PyList>,
    ) -> PyResult<(Option<Bound<'py, PyTuple>>, Option<bool>)> {
        // Decode the entry tuple: ((dirname, basename, file_id),
        // [tree_tuple, ...]).
        let entry_tup = entry.cast::<PyTuple>()?;
        let key_tup = entry_tup.get_item(0)?.cast_into::<PyTuple>()?;
        let entry_key = bazaar::dirstate::EntryKey {
            dirname: key_tup.get_item(0)?.extract()?,
            basename: key_tup.get_item(1)?.extract()?,
            file_id: key_tup.get_item(2)?.extract()?,
        };
        let trees_any = entry_tup.get_item(1)?;
        let mut entry_trees: Vec<bazaar::dirstate::TreeData> = Vec::new();
        for t in trees_any.try_iter()? {
            let tt = t?.cast_into::<PyTuple>()?;
            let mk_bytes: Vec<u8> = tt.get_item(0)?.extract()?;
            let minikind = decode_minikind(&mk_bytes)?;
            entry_trees.push(bazaar::dirstate::TreeData {
                minikind,
                fingerprint: tt.get_item(1)?.extract()?,
                size: tt.get_item(2)?.extract()?,
                executable: tt.get_item(3)?.extract()?,
                packed_stat: tt.get_item(4)?.extract()?,
            });
        }

        // Decode path_info. The 5-tuple shape Python uses is
        // (top_relpath, basename, kind, stat, abspath).
        let path_info_rs: Option<bazaar::dirstate::ProcessPathInfo> = if let Some(pi) = path_info {
            if pi.is_none() {
                None
            } else {
                let pt = pi.cast::<PyTuple>()?;
                let kind_obj = pt.get_item(2)?;
                let kind: Option<bazaar::osutils::Kind> = if kind_obj.is_none() {
                    None
                } else {
                    Some(kind_obj.extract::<bazaar::osutils::Kind>()?)
                };
                let stat_obj = pt.get_item(3)?;
                let abspath: Vec<u8> = pt.get_item(4)?.extract()?;
                let stat = bazaar::dirstate::StatInfo {
                    mode: stat_obj.getattr("st_mode")?.extract()?,
                    size: stat_obj.getattr("st_size")?.extract()?,
                    mtime: stat_obj.getattr("st_mtime")?.extract::<f64>()? as i64,
                    ctime: stat_obj.getattr("st_ctime")?.extract::<f64>()? as i64,
                    dev: stat_obj.getattr("st_dev")?.extract()?,
                    ino: stat_obj.getattr("st_ino")?.extract()?,
                };
                Some(bazaar::dirstate::ProcessPathInfo {
                    abspath,
                    kind,
                    stat,
                })
            }
        } else {
            None
        };

        // Build state from the Python-owned containers.  iter_changes
        // uses a richer ProcessEntryState; for process_entry (which
        // is called per-entry by Python's ProcessEntryPython loop)
        // the walk-only fields are unused.
        let mut pstate = bazaar::dirstate::ProcessEntryState {
            source_index,
            target_index,
            include_unchanged,
            want_unversioned: false,
            partial: false,
            supports_tree_reference: false,
            root_abspath: Vec::new(),
            searched_specific_files: collect_bytes_set(searched_specific_files)?,
            search_specific_files: collect_bytes_set(search_specific_files)?,
            search_specific_file_parents: std::collections::HashSet::new(),
            searched_exact_paths: std::collections::HashSet::new(),
            seen_ids: std::collections::HashSet::new(),
            new_dirname_to_file_id: collect_bytes_map(new_dirname_to_file_id)?,
            old_dirname_to_file_id: collect_bytes_map(old_dirname_to_file_id)?,
            last_source_parent: decode_last_parent(last_source_parent)?,
            last_target_parent: decode_last_parent(last_target_parent)?,
        };

        // Transport: PyFileTransport is the only implementor we have
        // on the pyo3 side, and process_entry only calls lstat /
        // read_link on it (both go through os.* directly rather than
        // the underlying file handle).  A dummy PyNone handle is
        // therefore safe here.
        let transport = PyFileTransport::new(
            pyo3::types::PyNone::get(py).to_owned().into_any().unbind(),
            bazaar::dirstate::LockState::Read,
        );

        let dirstate_path = self.inner.filename.to_string_lossy().into_owned();
        let (change, changed) = self
            .inner
            .process_entry(
                &mut pstate,
                &entry_key,
                &entry_trees,
                path_info_rs.as_ref(),
                &transport,
            )
            .map_err(|e| match e {
                bazaar::dirstate::ProcessEntryError::DirstateCorrupt(msg) => {
                    DirstateCorrupt::new_err((dirstate_path, msg))
                }
                bazaar::dirstate::ProcessEntryError::BadFileKind { path, mode } => {
                    bad_file_kind_error(py, &path, mode)
                }
                bazaar::dirstate::ProcessEntryError::Internal(msg) => {
                    pyo3::exceptions::PyAssertionError::new_err(msg)
                }
            })?;

        // Write back mutable state to the Python containers.
        write_back_bytes_set(search_specific_files, &pstate.search_specific_files)?;
        write_back_bytes_map(old_dirname_to_file_id, &pstate.old_dirname_to_file_id)?;
        write_back_bytes_map(new_dirname_to_file_id, &pstate.new_dirname_to_file_id)?;
        write_back_last_parent(last_source_parent, &pstate.last_source_parent)?;
        write_back_last_parent(last_target_parent, &pstate.last_target_parent)?;

        let change_tuple = change
            .map(|c| dirstate_change_to_pytuple(py, &c))
            .transpose()?;
        Ok((change_tuple, changed))
    }

    /// Forget all in-memory state. Mirrors `DirState._wipe_state`.
    #[pyo3(name = "_wipe_state")]
    fn wipe_state(&mut self, py: Python<'_>) {
        self.inner.wipe_state();
        self.refresh_cached_id_index(py);
    }

    /// Acquire a read lock on the dirstate file via the Transport.
    /// Mirrors Python's `DirState.lock_read`. Returns a
    /// `LogicalLockResult` whose `unlock` callback releases the
    /// lock.
    fn lock_read(slf: Bound<'_, Self>, py: Python<'_>) -> PyResult<Py<PyAny>> {
        {
            let me = slf.borrow();
            let mut t = me.transport.lock().unwrap();
            t.lock_read().map_err(transport_err_to_py)?;
        }
        slf.borrow_mut().inner.wipe_state();
        slf.borrow_mut().refresh_cached_id_index(py);
        let unlock = slf.getattr("unlock")?;
        let result = pyo3::Py::new(
            py,
            crate::lock::PyLogicalLockResult {
                unlock: unlock.unbind(),
                token: None,
            },
        )?;
        Ok(result.into_any())
    }

    /// Acquire a write lock on the dirstate file via the Transport.
    /// Mirrors Python's `DirState.lock_write`.
    fn lock_write(slf: Bound<'_, Self>, py: Python<'_>) -> PyResult<Py<PyAny>> {
        {
            let me = slf.borrow();
            let mut t = me.transport.lock().unwrap();
            t.lock_write().map_err(transport_err_to_py)?;
        }
        slf.borrow_mut().inner.wipe_state();
        slf.borrow_mut().refresh_cached_id_index(py);
        let unlock = slf.getattr("unlock")?;
        // Python's lock_write passes self._lock_token as the second
        // arg of LogicalLockResult; we pass slf for the same role
        // (callers historically use it as an opaque token).
        let result = pyo3::Py::new(
            py,
            crate::lock::PyLogicalLockResult {
                unlock: unlock.unbind(),
                token: Some(slf.clone().into_any().unbind()),
            },
        )?;
        Ok(result.into_any())
    }

    /// Release the held lock. Mirrors Python's `DirState.unlock`.
    fn unlock(&self) -> PyResult<()> {
        let mut t = self.transport.lock().unwrap();
        t.unlock().map_err(transport_err_to_py)?;
        Ok(())
    }

    /// Persist any pending changes through the held lock. Mirrors
    /// Python's `DirState.save`: skips when `changes_aborted` or the
    /// state is not worth saving, performs the read→write lock-upgrade
    /// dance through the transport, calls the pure-Rust `save_to`, and
    /// restores the read lock afterwards.
    fn save(&mut self) -> PyResult<()> {
        if self.inner.changes_aborted {
            return Ok(());
        }
        if !self.inner.worth_saving() {
            return Ok(());
        }
        let mut t = self.transport.lock().unwrap();
        let upgraded = match t.lock_state() {
            Some(bazaar::dirstate::LockState::Write) => false,
            Some(bazaar::dirstate::LockState::Read) => {
                if !t.upgrade_to_write_lock().map_err(transport_err_to_py)? {
                    // Another reader prevented the upgrade; matches
                    // Python's "grabbed_write_lock is False" return.
                    return Ok(());
                }
                true
            }
            None => return Err(ObjectNotLocked::new_err(())),
        };
        let save_result = self.inner.save_to(&mut **t);
        if upgraded {
            t.downgrade_to_read_lock().map_err(transport_err_to_py)?;
        }
        save_result.map_err(transport_err_to_py)?;
        Ok(())
    }

    /// Current lock mode held on the transport: `"r"`, `"w"`, or
    /// `None`. Mirrors Python's `DirState._lock_state` attribute.
    #[getter(_lock_state)]
    fn lock_state(&self) -> Option<&'static str> {
        match self.transport.lock().unwrap().lock_state() {
            None => None,
            Some(bazaar::dirstate::LockState::Read) => Some("r"),
            Some(bazaar::dirstate::LockState::Write) => Some("w"),
        }
    }

    /// Truthy iff the dirstate is currently locked. Kept as a
    /// back-compat attribute so `if state._lock_token:` checks
    /// continue to work; the actual OS lock lives on the transport.
    #[getter(_lock_token)]
    fn lock_token(slf: Bound<'_, Self>, py: Python<'_>) -> PyResult<Py<PyAny>> {
        if slf
            .borrow()
            .transport
            .lock()
            .unwrap()
            .lock_state()
            .is_some()
        {
            Ok(slf.into_any().unbind())
        } else {
            Ok(py.None())
        }
    }

    /// Raise `ObjectNotLocked` if no lock is held on the transport.
    /// Mirrors Python's `DirState._requires_lock`.
    fn _requires_lock(&self) -> PyResult<()> {
        let t = self.transport.lock().unwrap();
        if t.lock_state().is_none() {
            return Err(ObjectNotLocked::new_err(()));
        }
        Ok(())
    }

    /// Read the dirstate header from the transport iff it has not
    /// already been loaded. Mirrors Python's
    /// `DirState._read_header_if_needed`.
    fn _read_header_if_needed(slf: Bound<'_, Self>) -> PyResult<()> {
        let (data, already_loaded) = {
            let me = slf.borrow();
            let mut t = me.transport.lock().unwrap();
            if t.lock_state().is_none() {
                return Err(ObjectNotLocked::new_err(()));
            }
            if !matches!(
                me.inner.header_state,
                bazaar::dirstate::MemoryState::NotInMemory
            ) {
                return Ok(());
            }
            (t.read_all().map_err(transport_err_to_py)?, false)
        };
        let _ = already_loaded;
        let path = slf.borrow().inner.filename.to_string_lossy().into_owned();
        slf.borrow_mut()
            .inner
            .read_header(&data)
            .map_err(|e| DirstateCorrupt::new_err((path, e.to_string())))
    }

    /// Size of the dirstate file as seen through the transport.
    /// Used by `_prepare_bisect` to bound the bisect search.
    fn _state_file_size(&self) -> PyResult<u64> {
        let mut t = self.transport.lock().unwrap();
        t.len().map_err(transport_err_to_py)
    }

    /// Read the full dirstate file via the transport. Used by
    /// `get_lines` when both the header and dirblocks are
    /// `IN_MEMORY_UNMODIFIED` and the on-disk bytes are authoritative.
    fn _read_all<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
        let mut t = self.transport.lock().unwrap();
        let data = t.read_all().map_err(transport_err_to_py)?;
        Ok(PyBytes::new(py, &data))
    }

    /// Transport-backed counterpart of `_bisect`. Mirrors Python's
    /// `DirState._bisect`: prepares the header / file size on demand
    /// and bisects through the on-disk dirstate without materialising
    /// it in memory.
    fn _bisect<'py>(
        slf: Bound<'py, Self>,
        py: Python<'py>,
        paths: &Bound<PyAny>,
    ) -> PyResult<Bound<'py, PyDict>> {
        let file_size = Self::prepare_bisect(slf.clone())?;
        let rust_paths = collect_bytes_vec(paths)?;
        let found = {
            let me = slf.borrow();
            let mut t = me.transport.lock().unwrap();
            let mut read_range =
                |offset: u64, len: usize| -> Result<Vec<u8>, bazaar::dirstate::BisectError> {
                    t.read_at(offset, len)
                        .map_err(|e| bazaar::dirstate::BisectError::ReadError(e.to_string()))
                };
            me.inner
                .bisect(rust_paths, file_size, &mut read_range)
                .map_err(|e| bisect_err_to_py(slf.as_any(), e))?
        };
        bisect_result_to_pydict(py, &found)
    }

    /// Transport-backed counterpart of `_bisect_dirblocks`.
    fn _bisect_dirblocks<'py>(
        slf: Bound<'py, Self>,
        py: Python<'py>,
        dir_list: &Bound<PyAny>,
    ) -> PyResult<Bound<'py, PyDict>> {
        let file_size = Self::prepare_bisect(slf.clone())?;
        let rust_dirs = collect_bytes_vec(dir_list)?;
        let found = {
            let me = slf.borrow();
            let mut t = me.transport.lock().unwrap();
            let mut read_range =
                |offset: u64, len: usize| -> Result<Vec<u8>, bazaar::dirstate::BisectError> {
                    t.read_at(offset, len)
                        .map_err(|e| bazaar::dirstate::BisectError::ReadError(e.to_string()))
                };
            me.inner
                .bisect_dirblocks(rust_dirs, file_size, &mut read_range)
                .map_err(|e| bisect_err_to_py(slf.as_any(), e))?
        };
        bisect_result_to_pydict(py, &found)
    }

    /// Transport-backed counterpart of `_bisect_recursive`.
    fn _bisect_recursive<'py>(
        slf: Bound<'py, Self>,
        py: Python<'py>,
        paths: &Bound<PyAny>,
    ) -> PyResult<Bound<'py, PyDict>> {
        let file_size = Self::prepare_bisect(slf.clone())?;
        let rust_paths = collect_bytes_vec(paths)?;
        let found = {
            let me = slf.borrow();
            let mut t = me.transport.lock().unwrap();
            let mut read_range =
                |offset: u64, len: usize| -> Result<Vec<u8>, bazaar::dirstate::BisectError> {
                    t.read_at(offset, len)
                        .map_err(|e| bazaar::dirstate::BisectError::ReadError(e.to_string()))
                };
            me.inner
                .bisect_recursive(rust_paths, file_size, &mut read_range)
                .map_err(|e| bisect_err_to_py(slf.as_any(), e))?
        };
        let out = PyDict::new(py);
        for ((dn, bn, fid), trees) in &found {
            let key = PyTuple::new(
                py,
                [
                    PyBytes::new(py, dn).into_any(),
                    PyBytes::new(py, bn).into_any(),
                    PyBytes::new(py, fid).into_any(),
                ],
            )?;
            let tree_items: Vec<Bound<PyAny>> = trees
                .iter()
                .map(|t| {
                    PyTuple::new(
                        py,
                        [
                            PyBytes::new(py, &[t.minikind.to_minikind()]).into_any(),
                            PyBytes::new(py, &t.fingerprint).into_any(),
                            t.size.into_pyobject(py)?.into_any(),
                            pyo3::types::PyBool::new(py, t.executable)
                                .to_owned()
                                .into_any(),
                            PyBytes::new(py, &t.packed_stat).into_any(),
                        ],
                    )
                    .map(|tup| tup.into_any())
                })
                .collect::<PyResult<Vec<_>>>()?;
            out.set_item(key, PyList::new(py, tree_items)?)?;
        }
        Ok(out)
    }

    /// Parse the 5-line dirstate header out of `data`. Mirrors
    /// Python's `DirState._read_header`: populates parents, ghosts,
    /// num_entries, end_of_header, and marks the header as in-memory
    /// unmodified. `data` must be exactly the bytes of the five
    /// header lines as returned by sequential `readline()` calls on
    /// the state file — the resulting `end_of_header` equals
    /// `len(data)` so it matches `state_file.tell()` on the caller
    /// side.
    fn read_header(&mut self, data: &[u8]) -> PyResult<()> {
        self.inner
            .read_header(data)
            .map_err(|e| BzrFormatsError::new_err(e.to_string()))
    }

    /// Discard any parent trees beyond the first, including any
    /// entries that are dead in both tree 0 and tree 1 after the
    /// discard. Reads the header and dirblocks first if needed.
    /// Mirrors Python's `DirState._discard_merge_parents`.
    #[pyo3(name = "_discard_merge_parents")]
    fn discard_merge_parents(slf: Bound<'_, Self>, py: Python<'_>) -> PyResult<()> {
        Self::_read_header_if_needed(slf.clone())?;
        if slf.borrow().inner.parents.is_empty() {
            return Ok(());
        }
        read_dirblocks_if_needed(py, &slf)?;
        let mut me = slf.borrow_mut();
        me.inner.discard_merge_parents();
        me.refresh_cached_id_index(py);
        Ok(())
    }

    /// Split the root dirblock into two sentinel blocks: block 0 with
    /// the root row, block 1 with the contents-of-root rows. Mirrors
    /// Python's `DirState._split_root_dirblock_into_contents`. Raises
    /// `ValueError` when the pre-split layout is not the expected
    /// "everything in block 0, block 1 empty" shape.
    #[pyo3(name = "_split_root_dirblock_into_contents")]
    fn split_root_dirblock_into_contents(&mut self) -> PyResult<()> {
        self.inner
            .split_root_dirblock_into_contents()
            .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{:?}", e)))
    }

    /// Find the dirblock index whose dirname matches `key[0]`.
    /// Mirrors Python's `DirState._find_block_index_from_key` and
    /// returns `(block_index, present)`. Python's one-slot
    /// `_last_block_index` cache is dropped by this port — bisect in
    /// Rust is cheap enough that the extra branch isn't worth it.
    #[pyo3(name = "_find_block_index_from_key")]
    fn find_block_index_from_key(&self, key: &Bound<PyTuple>) -> PyResult<(usize, bool)> {
        let entry_key = bazaar::dirstate::EntryKey {
            dirname: key.get_item(0)?.extract()?,
            basename: key.get_item(1)?.extract()?,
            file_id: key.get_item(2)?.extract()?,
        };
        Ok(self.inner.find_block_index_from_key(&entry_key))
    }

    /// Overwrite the tree-0 slot of `key`'s entry with the provided
    /// details, without touching id_index, cross-references, or
    /// dirblock_state. Mirrors Python's old in-place
    /// `entry[1][0] = (...)` mutation used by `update_entry`'s
    /// hash-refresh path.
    #[pyo3(signature = (key, minikind, fingerprint, size, executable, packed_stat))]
    fn set_tree0(
        &mut self,
        key: &Bound<PyTuple>,
        minikind: &[u8],
        fingerprint: &[u8],
        size: u64,
        executable: bool,
        packed_stat: &[u8],
    ) -> PyResult<()> {
        let entry_key = bazaar::dirstate::EntryKey {
            dirname: key.get_item(0)?.extract()?,
            basename: key.get_item(1)?.extract()?,
            file_id: key.get_item(2)?.extract()?,
        };
        let details = bazaar::dirstate::TreeData {
            minikind: decode_minikind(minikind)?,
            fingerprint: fingerprint.to_vec(),
            size,
            executable,
            packed_stat: packed_stat.to_vec(),
        };
        self.inner
            .set_tree0(&entry_key, details)
            .map_err(|e| pyo3::exceptions::PyAssertionError::new_err(e.to_string()))
    }

    /// Find the entry index within `block` for `key`. Mirrors Python's
    /// `DirState._find_entry_index`, including the one-slot
    /// `_last_entry_index` mini-bisect cache: after a hit, the next
    /// call probes `block[last + 1]` directly before falling back to a
    /// full `bisect_left`. `block` is the
    /// `self._dirblocks[block_index][1]` list.
    #[pyo3(name = "_find_entry_index")]
    fn find_entry_index(
        &self,
        key: &Bound<PyTuple>,
        block: &Bound<PyAny>,
    ) -> PyResult<(usize, bool)> {
        let entry_key = bazaar::dirstate::EntryKey {
            dirname: key.get_item(0)?.extract()?,
            basename: key.get_item(1)?.extract()?,
            file_id: key.get_item(2)?.extract()?,
        };
        let mut entries: Vec<bazaar::dirstate::Entry> = Vec::new();
        for item in block.try_iter()? {
            entries.push(crate::dirstate_helpers::entry_from_py(&item?)?);
        }
        // Mini-bisect cache: check the slot just after the previously
        // returned index. A hit is when `key` is strictly greater than
        // the prior slot (block[last]) and `<=` the candidate slot
        // (block[last + 1]). On hit, return that index; on miss, fall
        // through to a full bisect.
        fn key_tuple(k: &bazaar::dirstate::EntryKey) -> (&[u8], &[u8], &[u8]) {
            (&k.dirname, &k.basename, &k.file_id)
        }
        let len_block = entries.len();
        let mut last_guard = self.last_entry_index.lock().unwrap();
        if let Some(last) = *last_guard {
            let probe = last + 1;
            if probe < len_block
                && key_tuple(&entries[last].key) < key_tuple(&entry_key)
                && key_tuple(&entry_key) <= key_tuple(&entries[probe].key)
            {
                let present = entries[probe].key == entry_key;
                *last_guard = Some(probe);
                return Ok((probe, present));
            }
        }
        let (idx, present) = self.inner.find_entry_index(&entry_key, &entries);
        *last_guard = Some(idx);
        Ok((idx, present))
    }

    /// Look up `(dirname, basename)` in `tree_index` and return the
    /// four-field result Python's `DirState._get_block_entry_index`
    /// produces: `(block_index, entry_index, dir_present,
    /// path_present)`.
    #[pyo3(name = "_get_block_entry_index")]
    fn get_block_entry_index(
        slf: Bound<'_, Self>,
        py: Python<'_>,
        dirname: &[u8],
        basename: &[u8],
        tree_index: usize,
    ) -> PyResult<(usize, usize, bool, bool)> {
        read_dirblocks_if_needed(py, &slf)?;
        let me = slf.borrow();
        let bei = me
            .inner
            .get_block_entry_index(dirname, basename, tree_index);
        Ok((
            bei.block_index,
            bei.entry_index,
            bei.dir_present,
            bei.path_present,
        ))
    }

    /// Ensure a dirblock for `dirname` exists. Mirrors Python's
    /// `DirState._ensure_block`: takes the (block_index, row_index)
    /// coordinates of the parent entry (used for the basename
    /// assertion) and returns the index of the block for `dirname`,
    /// creating an empty block if necessary. Raises `AssertionError`
    /// when the supplied dirname does not end with the parent entry's
    /// basename.
    #[pyo3(name = "_ensure_block")]
    fn ensure_block(
        &mut self,
        parent_block_index: isize,
        parent_row_index: isize,
        dirname: &[u8],
    ) -> PyResult<usize> {
        self.inner
            .ensure_block(parent_block_index, parent_row_index, dirname)
            .map_err(|e| pyo3::exceptions::PyAssertionError::new_err(format!("{:?}", e)))
    }

    /// Return the sha1 of the file whose packed_stat matches
    /// `packed_stat`, or `None` if no such file is present. Mirrors
    /// Python's `DirState.sha1_from_stat` slow path
    /// (`_get_packed_stat_index().get(pack_stat(stat))`). The caller
    /// provides the already-packed stat bytes since pack_stat is
    /// already a pure-Rust pyo3 function on the module.
    fn sha1_from_packed_stat<'py>(
        &mut self,
        py: Python<'py>,
        packed_stat: &[u8],
    ) -> Option<Bound<'py, PyBytes>> {
        self.inner
            .get_or_build_packed_stat_index()
            .get(packed_stat)
            .map(|sha1| PyBytes::new(py, sha1))
    }

    /// `DirState.sha1_from_stat`: pack the stat tuple and look up the
    /// resulting key in the packed-stat index in one call. `path` is
    /// kept as an ignored back-compat first argument since callers
    /// originally went through the wrapper class's
    /// `sha1_from_stat(path, stat_result)` signature.
    #[pyo3(signature = (path, stat_result))]
    fn sha1_from_stat<'py>(
        &mut self,
        py: Python<'py>,
        path: &Bound<'py, PyAny>,
        stat_result: &Bound<'py, PyAny>,
    ) -> PyResult<Option<Bound<'py, PyBytes>>> {
        let _ = path;
        let size = stat_result.getattr("st_size")?.extract::<u64>()?;
        let mtime = extract_fs_time(&stat_result.getattr("st_mtime")?)?;
        let ctime = extract_fs_time(&stat_result.getattr("st_ctime")?)?;
        let dev = stat_result.getattr("st_dev")?.extract::<u64>()?;
        let ino = stat_result.getattr("st_ino")?.extract::<u64>()?;
        let mode = stat_result.getattr("st_mode")?.extract::<u32>()?;
        let packed = bazaar::dirstate::pack_stat(size, mtime, ctime, dev, ino, mode);
        Ok(self
            .inner
            .get_or_build_packed_stat_index()
            .get(packed.as_bytes())
            .map(|sha1| PyBytes::new(py, sha1)))
    }

    /// Mark the entry at `key` as absent for tree 0, returning True
    /// when the entry row was removed entirely (the "last reference"
    /// case). Mirrors Python's `DirState._make_absent`. Input is the
    /// full dirstate `entry` (a `(key, tree_states)` 2-tuple), matching
    /// the shape Python's iter_entries / get_entry produces. Only the
    /// key (`entry[0]`) is consulted.
    #[pyo3(name = "_make_absent")]
    fn make_absent(&mut self, py: Python<'_>, entry: &Bound<PyTuple>) -> PyResult<bool> {
        let key = entry.get_item(0)?.cast_into::<PyTuple>()?;
        let entry_key = bazaar::dirstate::EntryKey {
            dirname: key.get_item(0)?.extract()?,
            basename: key.get_item(1)?.extract()?,
            file_id: key.get_item(2)?.extract()?,
        };
        let result = self
            .inner
            .make_absent(&entry_key)
            .map_err(|e| pyo3::exceptions::PyAssertionError::new_err(e.to_string()))?;
        self.refresh_cached_id_index(py);
        Ok(result)
    }

    /// Apply a sequence of "adds" to tree 1. Mirrors Python's
    /// `DirState._update_basis_apply_adds`. The input is a Python
    /// iterable of `(old_path, new_path, file_id, new_details,
    /// real_add)` 5-tuples where `new_details` itself is a
    /// `(minikind, fingerprint, size, executable, packed_stat)`
    /// 5-tuple, matching the shape Python's `update_basis_by_delta`
    /// passes through today.
    ///
    /// Raises `InconsistentDelta(path, file_id, reason)` for
    /// caller-visible delta problems (setting `changes_aborted` on
    /// the inner state first, mirroring Python's `_raise_invalid`),
    /// `NotImplementedError` for the basis-relocation branch, and
    /// `AssertionError` for internal invariant violations.
    #[pyo3(name = "_update_basis_apply_adds")]
    fn update_basis_apply_adds(&mut self, py: Python<'_>, adds: &Bound<PyAny>) -> PyResult<()> {
        let mut rust_adds: Vec<bazaar::dirstate::BasisAdd> = Vec::new();
        for item in adds.try_iter()? {
            let tup = item?.cast_into::<PyTuple>()?;
            if tup.len() != 5 {
                return Err(PyTypeError::new_err(
                    "update_basis_apply_adds entries must be 5-tuples",
                ));
            }
            let old_path: Option<Vec<u8>> = {
                let obj = tup.get_item(0)?;
                if obj.is_none() {
                    None
                } else {
                    Some(obj.extract()?)
                }
            };
            let new_path: Vec<u8> = tup.get_item(1)?.extract()?;
            let file_id: Vec<u8> = tup.get_item(2)?.extract()?;
            let details_tup = tup.get_item(3)?.cast_into::<PyTuple>()?;
            if details_tup.len() != 5 {
                return Err(PyTypeError::new_err(
                    "entry details tuple must have 5 fields",
                ));
            }
            let minikind_bytes: Vec<u8> = details_tup.get_item(0)?.extract()?;
            let new_details = bazaar::dirstate::TreeData {
                minikind: decode_minikind(&minikind_bytes)?,
                fingerprint: details_tup.get_item(1)?.extract()?,
                size: details_tup.get_item(2)?.extract()?,
                executable: details_tup.get_item(3)?.extract()?,
                packed_stat: details_tup.get_item(4)?.extract()?,
            };
            let real_add: bool = tup.get_item(4)?.extract()?;
            rust_adds.push(bazaar::dirstate::BasisAdd {
                old_path,
                new_path,
                file_id,
                new_details,
                real_add,
            });
        }

        match self.inner.update_basis_apply_adds(&mut rust_adds) {
            Ok(()) => {
                self.refresh_cached_id_index(py);
                Ok(())
            }
            Err(e) => Err(self.raise_basis_apply_error(py, e)),
        }
    }

    /// Look up a dirstate entry by path and/or file_id in
    /// `tree_index`. Mirrors Python's `DirState._get_entry` —
    /// including the `(None, None)` sentinel returned on a miss.
    /// On hit, the return is the same entry-tuple shape as
    /// `DirState.dirblocks` entries.
    ///
    /// `include_deleted` controls whether the file_id branch
    /// returns absent entries (`b'a'`) as-is or hides them.
    ///
    /// Raises `BzrFormatsError` for the "unversioned entry?" and
    /// "mismatching tree_index/file_id/path" guards; the second one
    /// also sets `changes_aborted` to match Python's side effect.
    #[pyo3(name = "_get_entry")]
    #[pyo3(signature = (
        tree_index,
        fileid_utf8 = None,
        path_utf8 = None,
        include_deleted = false,
    ))]
    fn get_entry<'py>(
        slf: Bound<'py, Self>,
        py: Python<'py>,
        tree_index: usize,
        fileid_utf8: Option<&[u8]>,
        path_utf8: Option<&[u8]>,
        include_deleted: bool,
    ) -> PyResult<Py<PyAny>> {
        read_dirblocks_if_needed(py, &slf)?;
        let mut binding = slf.borrow_mut();
        let self_ = &mut *binding;
        let none_pair = || -> PyResult<Py<PyAny>> {
            Ok(PyTuple::new(py, [py.None(), py.None()])?.unbind().into())
        };

        if let Some(path) = path_utf8 {
            // Path lookup branch.
            let (dirname_raw, basename_raw) = match path.iter().rposition(|&b| b == b'/') {
                Some(i) => (&path[..i], &path[i + 1..]),
                None => (b"".as_slice(), path),
            };
            let bei = self_
                .inner
                .get_block_entry_index(dirname_raw, basename_raw, tree_index);
            if !bei.path_present {
                return none_pair();
            }
            let entry = &self_.inner.dirblocks[bei.block_index].entries[bei.entry_index];
            let t_kind = entry.trees.get(tree_index).map(|t| t.minikind);
            if entry.key.file_id.is_empty()
                || matches!(
                    t_kind,
                    None | Some(bazaar::dirstate::Kind::Absent)
                        | Some(bazaar::dirstate::Kind::Relocated)
                )
            {
                return Err(BzrFormatsError::new_err("unversioned entry?"));
            }
            if let Some(fid) = fileid_utf8 {
                if entry.key.file_id != fid {
                    self_.inner.changes_aborted = true;
                    return Err(BzrFormatsError::new_err(
                        "integrity error ? : mismatching tree_index, file_id and path",
                    ));
                }
            }
            return Ok(entry_to_py_tuple(py, entry)?.unbind().into());
        }

        // file_id lookup branch.
        let fid = match fileid_utf8 {
            Some(f) => f,
            None => return none_pair(),
        };

        let file_id = bazaar::FileId::from(&fid.to_vec());
        let candidates = self_.inner.get_or_build_id_index().get(&file_id);

        let mut next_path: Option<Vec<u8>> = None;
        for (dn, bn, _) in candidates {
            let search_key = bazaar::dirstate::EntryKey {
                dirname: dn.clone(),
                basename: bn.clone(),
                file_id: fid.to_vec(),
            };
            let (b_idx, b_present) = self_.inner.find_block_index_from_key(&search_key);
            if !b_present {
                continue;
            }
            let (e_idx, e_present) = self_
                .inner
                .find_entry_index(&search_key, &self_.inner.dirblocks[b_idx].entries);
            if !e_present {
                continue;
            }
            let entry = &self_.inner.dirblocks[b_idx].entries[e_idx];
            let t_kind = entry.trees.get(tree_index).map(|t| t.minikind);
            match t_kind {
                Some(k) if k.is_fdlt() => {
                    return Ok(entry_to_py_tuple(py, entry)?.unbind().into());
                }
                Some(bazaar::dirstate::Kind::Absent) => {
                    if include_deleted {
                        return Ok(entry_to_py_tuple(py, entry)?.unbind().into());
                    }
                    return none_pair();
                }
                Some(bazaar::dirstate::Kind::Relocated) => {
                    let real_path = entry.trees[tree_index].fingerprint.clone();
                    next_path = Some(real_path);
                    break;
                }
                Some(_) | None => {
                    return Err(pyo3::exceptions::PyAssertionError::new_err(format!(
                        "entry has invalid minikind for tree {}",
                        tree_index
                    )));
                }
            }
        }

        if let Some(real_path) = next_path {
            drop(binding);
            return Self::get_entry(
                slf,
                py,
                tree_index,
                Some(fid),
                Some(&real_path),
                include_deleted,
            );
        }

        none_pair()
    }

    /// Check that every `(dirname_utf8, file_id)` pair in `parents`
    /// exists in `tree_index`. Mirrors Python's
    /// `DirState._after_delta_check_parents`. Raises
    /// `InconsistentDelta` on the first bad parent.
    #[pyo3(name = "_after_delta_check_parents")]
    fn after_delta_check_parents(
        &mut self,
        py: Python<'_>,
        parents: &Bound<PyAny>,
        index: usize,
    ) -> PyResult<()> {
        let mut pairs: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
        for item in parents.try_iter()? {
            let tup = item?.cast_into::<PyTuple>()?;
            if tup.len() != 2 {
                return Err(PyTypeError::new_err(
                    "after_delta_check_parents entries must be 2-tuples",
                ));
            }
            let dirname: Vec<u8> = tup.get_item(0)?.extract()?;
            let file_id: Vec<u8> = tup.get_item(1)?.extract()?;
            pairs.push((dirname, file_id));
        }
        match self.inner.after_delta_check_parents(&pairs, index) {
            Ok(()) => Ok(()),
            Err(e) => Err(self.raise_basis_apply_error(py, e)),
        }
    }

    /// Verify that none of `new_ids` is already present at a live
    /// entry in `tree_index`. Mirrors Python's
    /// `DirState._check_delta_ids_absent`. Raises
    /// `InconsistentDelta` on conflict, via the shared
    /// `raise_basis_apply_error` helper.
    #[pyo3(name = "_check_delta_ids_absent")]
    fn check_delta_ids_absent(
        &mut self,
        py: Python<'_>,
        new_ids: &Bound<PyAny>,
        tree_index: usize,
    ) -> PyResult<()> {
        let mut ids: Vec<Vec<u8>> = Vec::new();
        for item in new_ids.try_iter()? {
            ids.push(item?.extract()?);
        }
        match self.inner.check_delta_ids_absent(&ids, tree_index) {
            Ok(()) => Ok(()),
            Err(e) => Err(self.raise_basis_apply_error(py, e)),
        }
    }

    /// Update a single entry in tree 0. Mirrors Python's
    /// `DirState.update_minimal`. Inputs are passed as separate
    /// positional arguments rather than bundled into a tuple to
    /// match the Python signature byte-for-byte:
    /// - `key` — a `(dirname, basename, file_id)` 3-tuple;
    /// - `minikind` — a one-byte `bytes` object;
    /// - `executable` — bool;
    /// - `fingerprint` — bytes (defaults to `b""`);
    /// - `packed_stat` — bytes or None (None is treated as
    ///   NULLSTAT);
    /// - `size` — unsigned int;
    /// - `path_utf8` — bytes or None (required when the
    ///   cross-reference branch runs);
    /// - `fullscan` — bool.
    ///
    /// Raises `InconsistentDelta` / `NotImplementedError` /
    /// `AssertionError` via the shared `raise_basis_apply_error`
    /// helper.
    #[pyo3(signature = (
        key,
        minikind,
        executable = false,
        fingerprint = None,
        packed_stat = None,
        size = 0,
        path_utf8 = None,
        fullscan = false,
    ))]
    #[allow(clippy::too_many_arguments)]
    fn update_minimal(
        &mut self,
        py: Python<'_>,
        key: &Bound<PyTuple>,
        minikind: &[u8],
        executable: bool,
        fingerprint: Option<&[u8]>,
        packed_stat: Option<&[u8]>,
        size: u64,
        path_utf8: Option<&[u8]>,
        fullscan: bool,
    ) -> PyResult<()> {
        let entry_key = bazaar::dirstate::EntryKey {
            dirname: key.get_item(0)?.extract()?,
            basename: key.get_item(1)?.extract()?,
            file_id: key.get_item(2)?.extract()?,
        };
        let packed_stat_bytes = match packed_stat {
            Some(s) => s.to_vec(),
            None => b"x".repeat(32), // DirState.NULLSTAT is 32 `x` bytes.
        };
        let tree0_details = bazaar::dirstate::TreeData {
            minikind: decode_minikind(minikind)?,
            fingerprint: fingerprint.unwrap_or(b"").to_vec(),
            size,
            executable,
            packed_stat: packed_stat_bytes,
        };
        match self
            .inner
            .update_minimal(entry_key, tree0_details, path_utf8, fullscan)
        {
            Ok(()) => {
                self.refresh_cached_id_index(py);
                Ok(())
            }
            Err(e) => Err(self.raise_basis_apply_error(py, e)),
        }
    }

    /// Add a new tracked entry. Mirrors Python's `DirState.add` after
    /// path normalisation: the caller hands in the already-normalised
    /// utf8 path, its `(dirname, basename)` split, the file id, kind
    /// string, size, packed_stat bytes, and fingerprint bytes.
    ///
    /// Raises `DuplicateFileId` when the file_id already lives at a
    /// live path, a bare `Exception("adding already added path!")`
    /// when a different file_id already occupies `(dirname,
    /// basename)`, `NotVersionedError` when the parent dir is missing,
    /// `BzrFormatsError` for unknown kinds, and `AssertionError` for
    /// internal invariant violations.
    #[allow(clippy::too_many_arguments)]
    #[pyo3(name = "_add_raw")]
    fn add(
        &mut self,
        py: Python<'_>,
        utf8path: &[u8],
        dirname: &[u8],
        basename: &[u8],
        file_id: &[u8],
        kind: bazaar::osutils::Kind,
        size: u64,
        packed_stat: &[u8],
        fingerprint: Option<&[u8]>,
    ) -> PyResult<()> {
        match self.inner.add(
            utf8path,
            dirname,
            basename,
            file_id,
            kind,
            size,
            packed_stat,
            fingerprint.unwrap_or(b""),
        ) {
            Ok(()) => {
                self.refresh_cached_id_index(py);
                Ok(())
            }
            Err(e) => Err(add_error_to_py(py, e)),
        }
    }

    /// Add a new tracked entry starting from an unsplit, possibly
    /// unnormalised path string.  Mirrors Python's full
    /// `DirState.add` body: splits the path, NFC-normalises the
    /// basename, rejects `.`/`..`, packs the stat tuple, and
    /// dispatches to the pure-crate `add`.
    ///
    /// `stat` is either `None` (substitutes NULLSTAT) or any object
    /// exposing `st_mode`/`st_size`/`st_mtime`/`st_ctime`/`st_dev`/
    /// `st_ino` — matching `os.stat_result`.
    #[pyo3(name = "add")]
    #[pyo3(signature = (path, file_id, kind, stat, fingerprint))]
    fn add_path(
        &mut self,
        py: Python<'_>,
        path: &Bound<'_, PyAny>,
        file_id: &[u8],
        kind: bazaar::osutils::Kind,
        stat: Option<&Bound<PyAny>>,
        fingerprint: Option<&[u8]>,
    ) -> PyResult<()> {
        // Accept either `str` (the canonical shape) or `bytes` (a few
        // legacy callers still pass utf-8 bytes for `path`).
        let path_owned: String = if let Ok(b) = path.downcast::<PyBytes>() {
            String::from_utf8(b.as_bytes().to_vec())
                .map_err(|e| PyTypeError::new_err(format!("path is not valid utf-8: {e}")))?
        } else {
            path.extract::<String>()?
        };
        let stat_info: Option<bazaar::dirstate::StatInfo> = match stat {
            None => None,
            Some(s) if s.is_none() => None,
            Some(s) => Some(bazaar::dirstate::StatInfo {
                mode: s.getattr("st_mode")?.extract()?,
                size: s.getattr("st_size")?.extract()?,
                mtime: s.getattr("st_mtime")?.extract::<f64>()? as i64,
                ctime: s.getattr("st_ctime")?.extract::<f64>()? as i64,
                dev: s.getattr("st_dev")?.extract()?,
                ino: s.getattr("st_ino")?.extract()?,
            }),
        };
        match self.inner.add_path(
            &path_owned,
            file_id,
            kind,
            stat_info,
            fingerprint.unwrap_or(b""),
        ) {
            Ok(()) => {
                self.refresh_cached_id_index(py);
                Ok(())
            }
            Err(e) => Err(add_error_to_py(py, e)),
        }
    }

    /// Change the file id of the root path. Mirrors Python's
    /// `DirState.set_path_id` for `path=b""` — any other path raises
    /// `NotImplementedError`. Returns silently when `new_id` already
    /// matches the current root id.
    fn set_path_id(
        slf: Bound<'_, Self>,
        py: Python<'_>,
        path: &[u8],
        new_id: &[u8],
    ) -> PyResult<()> {
        read_dirblocks_if_needed(py, &slf)?;
        let mut me = slf.borrow_mut();
        match me.inner.set_path_id(path, new_id) {
            Ok(()) => {
                me.refresh_cached_id_index(py);
                Ok(())
            }
            Err(bazaar::dirstate::SetPathIdError::NonRootPath) => Err(
                pyo3::exceptions::PyNotImplementedError::new_err("set_path_id non-root path"),
            ),
            Err(bazaar::dirstate::SetPathIdError::Internal { reason }) => {
                Err(pyo3::exceptions::PyAssertionError::new_err(reason))
            }
        }
    }

    /// Apply a sequence of "removals" to tree 0. Mirrors Python's
    /// `DirState._apply_removals`. Input is a Python iterable of
    /// `(file_id, path)` 2-tuples, matching the caller pattern
    /// `update_by_delta` uses: `removals.items()`.
    #[pyo3(name = "_apply_removals")]
    fn apply_removals(&mut self, py: Python<'_>, removals: &Bound<PyAny>) -> PyResult<()> {
        let mut rust_removals: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
        for item in removals.try_iter()? {
            let tup = item?.cast_into::<PyTuple>()?;
            if tup.len() != 2 {
                return Err(PyTypeError::new_err(
                    "apply_removals entries must be 2-tuples",
                ));
            }
            let file_id: Vec<u8> = tup.get_item(0)?.extract()?;
            let path: Vec<u8> = tup.get_item(1)?.extract()?;
            rust_removals.push((file_id, path));
        }
        match self.inner.apply_removals(&rust_removals) {
            Ok(()) => {
                self.refresh_cached_id_index(py);
                Ok(())
            }
            Err(e) => Err(self.raise_basis_apply_error(py, e)),
        }
    }

    /// Walk the dirblocks and verify `DirState._validate`'s
    /// invariants. On violation raises `AssertionError` with the
    /// same message Python would — mirroring
    /// `DirState._validate`.
    #[pyo3(name = "_validate")]
    fn validate(slf: Bound<'_, Self>, py: Python<'_>) -> PyResult<()> {
        read_dirblocks_if_needed(py, &slf)?;
        slf.borrow()
            .inner
            .validate()
            .map_err(|e| pyo3::exceptions::PyAssertionError::new_err(e.to_string()))
    }

    /// Apply an inventory delta to tree 1.  Mirrors Python's
    /// `DirState.update_basis_by_delta` end-to-end: takes the raw
    /// `InventoryDelta` pyclass directly (no Python-side flattening),
    /// validates each row's `file_id` against its `new_entry`, and
    /// dispatches to the Rust applier.
    fn update_basis_by_delta(
        slf: Bound<'_, Self>,
        py: Python<'_>,
        delta: Bound<'_, crate::inventory::InventoryDelta>,
        new_revid: Vec<u8>,
    ) -> PyResult<()> {
        read_dirblocks_if_needed(py, &slf)?;
        delta.borrow().check(py)?;
        delta.borrow_mut().sort();
        let delta_ref = delta.borrow();
        let mut me = slf.borrow_mut();
        match me
            .inner
            .update_basis_by_delta_from_inventory_delta(&delta_ref.0, new_revid)
        {
            Ok(()) => {
                me.refresh_cached_id_index(py);
                Ok(())
            }
            Err(e) => {
                me.inner.changes_aborted = true;
                Err(me.raise_basis_apply_error(py, e))
            }
        }
    }

    /// Apply an inventory delta to tree 0.  Takes an `InventoryDelta`
    /// pyclass; Rust does the per-row flattening and dispatch.
    fn update_by_delta(
        slf: Bound<'_, Self>,
        py: Python<'_>,
        delta: Bound<'_, crate::inventory::InventoryDelta>,
    ) -> PyResult<()> {
        read_dirblocks_if_needed(py, &slf)?;
        delta.borrow().check(py)?;
        delta.borrow_mut().sort();
        let delta_ref = delta.borrow();
        let mut me = slf.borrow_mut();
        match me.inner.update_by_delta_from_inventory_delta(&delta_ref.0) {
            Ok(()) => {
                drop(delta_ref);
                me.refresh_cached_id_index(py);
                Ok(())
            }
            Err(e) => {
                drop(delta_ref);
                me.inner.changes_aborted = true;
                Err(me.raise_basis_apply_error(py, e))
            }
        }
    }

    /// Apply a sequence of "insertions" to tree 0. Mirrors Python's
    /// `DirState._apply_insertions`. Input is a Python iterable of
    /// `(key, minikind, executable, fingerprint, path_utf8)` 5-tuples
    /// matching the shape assembled by `update_by_delta`.
    #[pyo3(name = "_apply_insertions")]
    fn apply_insertions(&mut self, py: Python<'_>, adds: &Bound<PyAny>) -> PyResult<()> {
        let mut rust_adds: Vec<(
            bazaar::dirstate::EntryKey,
            bazaar::dirstate::Kind,
            bool,
            Vec<u8>,
            Vec<u8>,
        )> = Vec::new();
        for item in adds.try_iter()? {
            let tup = item?.cast_into::<PyTuple>()?;
            if tup.len() != 5 {
                return Err(PyTypeError::new_err(
                    "apply_insertions entries must be 5-tuples",
                ));
            }
            let key_tup = tup.get_item(0)?.cast_into::<PyTuple>()?;
            let key = bazaar::dirstate::EntryKey {
                dirname: key_tup.get_item(0)?.extract()?,
                basename: key_tup.get_item(1)?.extract()?,
                file_id: key_tup.get_item(2)?.extract()?,
            };
            let minikind_bytes: Vec<u8> = tup.get_item(1)?.extract()?;
            let minikind = decode_minikind(&minikind_bytes)?;
            let executable: bool = tup.get_item(2)?.extract()?;
            let fingerprint: Vec<u8> = tup.get_item(3)?.extract()?;
            let path_utf8: Vec<u8> = tup.get_item(4)?.extract()?;
            rust_adds.push((key, minikind, executable, fingerprint, path_utf8));
        }
        match self.inner.apply_insertions(rust_adds) {
            Ok(()) => {
                self.refresh_cached_id_index(py);
                Ok(())
            }
            Err(e) => Err(self.raise_basis_apply_error(py, e)),
        }
    }

    /// Apply a sequence of "changes" to tree 1. Mirrors Python's
    /// `DirState._update_basis_apply_changes`. Input is a Python
    /// iterable of `(old_path, new_path, file_id, new_details)`
    /// 4-tuples; `new_details` is the same 5-tuple layout used by
    /// `update_basis_apply_adds`. Raises `InconsistentDelta` on a
    /// stale entry.
    #[pyo3(name = "_update_basis_apply_changes")]
    fn update_basis_apply_changes(
        &mut self,
        py: Python<'_>,
        changes: &Bound<PyAny>,
    ) -> PyResult<()> {
        let mut rust_changes: Vec<(Vec<u8>, Vec<u8>, Vec<u8>, bazaar::dirstate::TreeData)> =
            Vec::new();
        for item in changes.try_iter()? {
            let tup = item?.cast_into::<PyTuple>()?;
            if tup.len() != 4 {
                return Err(PyTypeError::new_err(
                    "update_basis_apply_changes entries must be 4-tuples",
                ));
            }
            let old_path: Vec<u8> = tup.get_item(0)?.extract()?;
            let new_path: Vec<u8> = tup.get_item(1)?.extract()?;
            let file_id: Vec<u8> = tup.get_item(2)?.extract()?;
            let details_tup = tup.get_item(3)?.cast_into::<PyTuple>()?;
            let minikind_bytes: Vec<u8> = details_tup.get_item(0)?.extract()?;
            let new_details = bazaar::dirstate::TreeData {
                minikind: decode_minikind(&minikind_bytes)?,
                fingerprint: details_tup.get_item(1)?.extract()?,
                size: details_tup.get_item(2)?.extract()?,
                executable: details_tup.get_item(3)?.extract()?,
                packed_stat: details_tup.get_item(4)?.extract()?,
            };
            rust_changes.push((old_path, new_path, file_id, new_details));
        }
        match self.inner.update_basis_apply_changes(&rust_changes) {
            Ok(()) => {
                self.refresh_cached_id_index(py);
                Ok(())
            }
            Err(e) => Err(self.raise_basis_apply_error(py, e)),
        }
    }

    /// Apply a sequence of "deletes" to tree 1. Mirrors Python's
    /// `DirState._update_basis_apply_deletes`. Input is a Python
    /// iterable of `(old_path, new_path_or_None, file_id, _ignored,
    /// real_delete)` 5-tuples — the 4th element is unused by the
    /// Python implementation (it carries `None` in the current
    /// caller) but we accept it to preserve the existing wire shape.
    #[pyo3(name = "_update_basis_apply_deletes")]
    fn update_basis_apply_deletes(
        &mut self,
        py: Python<'_>,
        deletes: &Bound<PyAny>,
    ) -> PyResult<()> {
        let mut rust_deletes: Vec<(Vec<u8>, Option<Vec<u8>>, Vec<u8>, bool)> = Vec::new();
        for item in deletes.try_iter()? {
            let tup = item?.cast_into::<PyTuple>()?;
            if tup.len() != 5 {
                return Err(PyTypeError::new_err(
                    "update_basis_apply_deletes entries must be 5-tuples",
                ));
            }
            let old_path: Vec<u8> = tup.get_item(0)?.extract()?;
            let new_path: Option<Vec<u8>> = {
                let obj = tup.get_item(1)?;
                if obj.is_none() {
                    None
                } else {
                    Some(obj.extract()?)
                }
            };
            let file_id: Vec<u8> = tup.get_item(2)?.extract()?;
            // tup.get_item(3) ignored — matches Python's `_` binding.
            let real_delete: bool = tup.get_item(4)?.extract()?;
            rust_deletes.push((old_path, new_path, file_id, real_delete));
        }
        match self.inner.update_basis_apply_deletes(&rust_deletes) {
            Ok(()) => {
                self.refresh_cached_id_index(py);
                Ok(())
            }
            Err(e) => Err(self.raise_basis_apply_error(py, e)),
        }
    }

    /// Replace the current tree-0 state with entries from the given
    /// inventory. Mirrors Python's `DirState.set_state_from_inventory`.
    /// Walks `new_inv.iter_entries_by_dir()` to build the per-entry row
    /// list and passes it through to the pure-Rust state builder.
    fn set_state_from_inventory(
        slf: Bound<'_, Self>,
        py: Python<'_>,
        new_inv: &Bound<PyAny>,
    ) -> PyResult<()> {
        read_dirblocks_if_needed(py, &slf)?;
        let mut rows: Vec<(Vec<u8>, Vec<u8>, bazaar::dirstate::Kind, Vec<u8>, bool)> = Vec::new();
        let iter = new_inv.call_method0("iter_entries_by_dir")?;
        for item in iter.try_iter()? {
            let pair = item?.cast_into::<PyTuple>()?;
            let path: String = pair.get_item(0)?.extract()?;
            let inv_entry = pair.get_item(1)?;
            let kind_str: String = inv_entry.getattr("kind")?.extract()?;
            let minikind = kind_to_minikind(&kind_str)?;
            let fingerprint: Vec<u8> = if matches!(minikind, bazaar::dirstate::Kind::TreeReference)
            {
                inv_entry
                    .getattr("reference_revision")
                    .ok()
                    .and_then(|v| if v.is_none() { None } else { v.extract().ok() })
                    .unwrap_or_default()
            } else {
                Vec::new()
            };
            let executable: bool = inv_entry
                .getattr("executable")
                .ok()
                .and_then(|v| v.extract().ok())
                .unwrap_or(false);
            let file_id: Vec<u8> = inv_entry.getattr("file_id")?.extract()?;
            rows.push((
                path.into_bytes(),
                file_id,
                minikind,
                fingerprint,
                executable,
            ));
        }
        let result = slf.borrow_mut().inner.set_state_from_inventory(rows);
        match result {
            Ok(()) => {
                slf.borrow_mut().refresh_cached_id_index(py);
                Ok(())
            }
            Err(e) => Err(slf.borrow_mut().raise_basis_apply_error(py, e)),
        }
    }

    /// Replace the parent trees. Mirrors Python's
    /// `DirState.set_parent_trees`. Input:
    ///
    /// - `trees`: iterable of `(revid_bytes, tree)` pairs, one per
    ///   parent (including ghosts), in order. The tree must expose
    ///   `iter_entries_by_dir()` returning `(path_str, inv_entry)`
    ///   pairs, where each `inv_entry` has `file_id`, `kind`,
    ///   `executable`, and (when applicable) `reference_revision`.
    /// - `ghosts`: iterable of `bytes` revision ids that are ghosts.
    fn set_parent_trees(
        slf: Bound<'_, Self>,
        py: Python<'_>,
        trees: &Bound<PyAny>,
        ghosts: &Bound<PyAny>,
    ) -> PyResult<()> {
        read_dirblocks_if_needed(py, &slf)?;
        let rust_ghosts = collect_bytes_vec(ghosts)?;
        let ghosts_set: std::collections::HashSet<Vec<u8>> = rust_ghosts.iter().cloned().collect();
        let mut rust_trees: Vec<Vec<u8>> = Vec::new();
        let mut rust_entries: Vec<Vec<(Vec<u8>, Vec<u8>, bazaar::dirstate::TreeData)>> = Vec::new();
        for item in trees.try_iter()? {
            let pair = item?.cast_into::<PyTuple>()?;
            let revid: Vec<u8> = pair.get_item(0)?.extract()?;
            let tree = pair.get_item(1)?;
            rust_trees.push(revid.clone());
            if ghosts_set.contains(&revid) {
                continue;
            }
            let mut tree_rows: Vec<(Vec<u8>, Vec<u8>, bazaar::dirstate::TreeData)> = Vec::new();
            let iter = tree.call_method0("iter_entries_by_dir")?;
            for row in iter.try_iter()? {
                let row_pair = row?.cast_into::<PyTuple>()?;
                let path_str: String = row_pair.get_item(0)?.extract()?;
                let inv_entry = row_pair.get_item(1)?;
                let details_tup = inv_entry_to_details_tuple(py, &inv_entry)?;
                let minikind_bytes: Vec<u8> = details_tup.get_item(0)?.extract()?;
                let minikind = decode_minikind(&minikind_bytes)?;
                let fingerprint: Vec<u8> = details_tup.get_item(1)?.extract()?;
                let size: u64 = details_tup.get_item(2)?.extract()?;
                let executable: bool = details_tup.get_item(3)?.extract()?;
                let packed_stat: Vec<u8> = details_tup.get_item(4)?.extract()?;
                let file_id: Vec<u8> = inv_entry.getattr("file_id")?.extract()?;
                tree_rows.push((
                    path_str.into_bytes(),
                    file_id,
                    bazaar::dirstate::TreeData {
                        minikind,
                        fingerprint,
                        size,
                        executable,
                        packed_stat,
                    },
                ));
            }
            rust_entries.push(tree_rows);
        }
        {
            let mut me = slf.borrow_mut();
            me.inner
                .set_parent_trees(rust_trees, rust_ghosts, rust_entries)
                .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{:?}", e)))?;
            me.refresh_cached_id_index(py);
        }
        Ok(())
    }

    /// Wipe the in-memory state and rebuild it from an in-memory
    /// working inventory plus parent trees. Mirrors Python's
    /// `DirState.set_state_from_scratch`.
    fn set_state_from_scratch(
        slf: Bound<'_, Self>,
        py: Python<'_>,
        working_inv: &Bound<PyAny>,
        parent_trees: &Bound<PyAny>,
        parent_ghosts: &Bound<PyAny>,
    ) -> PyResult<()> {
        slf.borrow()._requires_lock()?;
        // Empty tree-0 + empty contents-of-root so set_state_from_inventory
        // has something to zip against.
        let empty_root = PyTuple::new(
            py,
            [
                PyTuple::new(
                    py,
                    [
                        PyBytes::new(py, b"").into_any(),
                        PyBytes::new(py, b"").into_any(),
                        PyBytes::new(py, bazaar::inventory::ROOT_ID).into_any(),
                    ],
                )?
                .into_any(),
                PyList::new(
                    py,
                    [PyTuple::new(
                        py,
                        [
                            PyBytes::new(py, b"d").into_any(),
                            PyBytes::new(py, b"").into_any(),
                            0i64.into_pyobject(py)?.into_any(),
                            pyo3::types::PyBool::new(py, false).to_owned().into_any(),
                            Self::NULLSTAT(py).into_any(),
                        ],
                    )?],
                )?
                .into_any(),
            ],
        )?;
        let blocks = PyList::new(
            py,
            [
                PyTuple::new(
                    py,
                    [
                        PyBytes::new(py, b"").into_any(),
                        PyList::new(py, [empty_root])?.into_any(),
                    ],
                )?,
                PyTuple::new(
                    py,
                    [
                        PyBytes::new(py, b"").into_any(),
                        PyList::empty(py).into_any(),
                    ],
                )?,
            ],
        )?;
        let empty_parents = PyList::empty(py);
        slf.borrow_mut()
            .set_data(py, empty_parents.as_any(), blocks.as_any())?;
        Self::set_state_from_inventory(slf.clone(), py, working_inv)?;
        Self::set_parent_trees(slf, py, parent_trees, parent_ghosts)?;
        Ok(())
    }

    /// Create a dirstate file at `path` from a bzr `Tree`. Locks
    /// for writing and populates with the tree's parents and
    /// inventory; mirrors Python's `DirState.from_tree`.
    #[classmethod]
    #[pyo3(signature = (tree, dir_state_filename, sha1_provider = None))]
    fn from_tree(
        cls: &Bound<'_, pyo3::types::PyType>,
        py: Python<'_>,
        tree: &Bound<PyAny>,
        dir_state_filename: &Bound<PyAny>,
        sha1_provider: Option<&Bound<PyAny>>,
    ) -> PyResult<Py<Self>> {
        let result = Self::initialize(cls, py, dir_state_filename, sha1_provider)?;
        let bound = result.bind(py);
        let outcome = (|| -> PyResult<()> {
            let _tree_lock = tree.call_method0("lock_read")?;
            let parent_ids_obj = tree.call_method0("get_parent_ids")?;
            let parent_ids: Vec<Vec<u8>> = collect_bytes_vec(&parent_ids_obj)?;
            let branch = tree.getattr("branch")?;
            let repository = branch.getattr("repository")?;
            let parent_trees_list = PyList::empty(py);
            let mut parent_tree_locks: Vec<Bound<PyAny>> = Vec::new();
            for revid in &parent_ids {
                let revid_obj = PyBytes::new(py, revid);
                let pt = repository.call_method1("revision_tree", (revid_obj.clone(),))?;
                let pt_lock = pt.call_method0("lock_read")?;
                parent_tree_locks.push(pt_lock);
                let pair = PyTuple::new(py, [revid_obj.into_any(), pt])?;
                parent_trees_list.append(pair)?;
            }
            let empty_ghosts = PyList::empty(py);
            Self::set_parent_trees(
                bound.clone(),
                py,
                parent_trees_list.as_any(),
                empty_ghosts.as_any(),
            )?;
            let root_inv = tree.getattr("root_inventory")?;
            Self::set_state_from_inventory(bound.clone(), py, &root_inv)?;
            for lock in parent_tree_locks.iter().rev() {
                let _ = lock.call_method0("unlock");
            }
            let _ = _tree_lock.call_method0("unlock");
            Ok(())
        })();
        if let Err(e) = outcome {
            bound.borrow().unlock()?;
            return Err(e);
        }
        Ok(result)
    }

    /// Replace the entire in-memory state with `parent_ids` and
    /// `dirblocks` (both in the Python tuple shape), marking both the
    /// header and the dirblock data fully modified. Mirrors Python's
    /// `DirState._set_data`. Invalidates the cached id_index.
    #[pyo3(name = "_set_data")]
    fn set_data(
        &mut self,
        py: Python<'_>,
        parent_ids: &Bound<PyAny>,
        dirblocks: &Bound<PyAny>,
    ) -> PyResult<()> {
        let parents = collect_bytes_vec(parent_ids)?;
        let blocks = crate::dirstate_helpers::dirblocks_from_py(dirblocks)?;
        self.inner.set_data(parents, blocks);
        self.refresh_cached_id_index(py);
        Ok(())
    }

    /// Rebuild dirblocks from a flat, sorted list of entries.
    /// Mirrors Python's `DirState._entries_to_current_state`:
    /// assembles per-directory dirblocks from the sorted entry
    /// stream and runs `split_root_dirblock_into_contents` at the
    /// end so the two empty-dirname sentinel blocks are present.
    ///
    /// The input is a Python iterable of entry tuples in the same
    /// shape as `DirState.dirblocks` entries. Raises `ValueError`
    /// if the entry list is empty or does not start with the root
    /// row.
    #[pyo3(name = "_entries_to_current_state")]
    fn entries_to_current_state(&mut self, new_entries: &Bound<PyAny>) -> PyResult<()> {
        let mut entries: Vec<bazaar::dirstate::Entry> = Vec::new();
        for item in new_entries.try_iter()? {
            let item = item?;
            entries.push(crate::dirstate_helpers::entry_from_py(&item)?);
        }
        self.inner
            .entries_to_current_state(entries)
            .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{:?}", e)))
    }

    /// Mark the dirstate as modified. `hash_changed_entries` is an
    /// optional iterable of dirstate entries (each
    /// `(key, [tree_states])`, where `key` is the
    /// `(dirname, basename, file_id)` 3-tuple); pass `None` for a
    /// full modification. Mirrors `DirState._mark_modified`.
    #[pyo3(name = "_mark_modified")]
    #[pyo3(signature = (hash_changed_entries = None, header_modified = false))]
    fn mark_modified(
        &mut self,
        hash_changed_entries: Option<&Bound<PyAny>>,
        header_modified: bool,
    ) -> PyResult<()> {
        let mut keys: Vec<bazaar::dirstate::EntryKey> = Vec::new();
        if let Some(iter) = hash_changed_entries {
            for item in iter.try_iter()? {
                let entry = item?.cast_into::<PyTuple>()?;
                if entry.len() < 1 {
                    return Err(PyTypeError::new_err(
                        "hash_changed_entries items must be (key, ...) tuples",
                    ));
                }
                let key_tup = entry.get_item(0)?.cast_into::<PyTuple>()?;
                if key_tup.len() != 3 {
                    return Err(PyTypeError::new_err(
                        "entry key must be a (dirname, basename, file_id) 3-tuple",
                    ));
                }
                let dirname: Vec<u8> = key_tup.get_item(0)?.extract()?;
                let basename: Vec<u8> = key_tup.get_item(1)?.extract()?;
                let file_id: Vec<u8> = key_tup.get_item(2)?.extract()?;
                keys.push(bazaar::dirstate::EntryKey {
                    dirname,
                    basename,
                    file_id,
                });
            }
        }
        self.inner.mark_modified(&keys, header_modified);
        Ok(())
    }

    /// Mark the dirstate as unmodified. Mirrors
    /// `DirState._mark_unmodified`.
    #[pyo3(name = "_mark_unmodified")]
    fn mark_unmodified(&mut self) {
        self.inner.mark_unmodified();
    }

    /// Read-only view of the cached `IdIndex`: returns `None` if
    /// nothing has been cached yet. The Python `DirState._id_index`
    /// property mirrors this for back-compat with code that pokes at
    /// the cached attribute directly.
    #[getter(_id_index)]
    fn get_cached_id_index(&self, py: Python<'_>) -> Option<Py<IdIndex>> {
        self.id_index
            .lock()
            .unwrap()
            .as_ref()
            .map(|p| p.clone_ref(py))
    }

    /// Setter for `_id_index`. Only accepts `None` (which drops the
    /// cache); anything else is a TypeError. Mirrors the Python
    /// property setter that used to enforce the same invariant.
    #[setter(_id_index)]
    fn set_cached_id_index(&self, value: &Bound<PyAny>) -> PyResult<()> {
        if !value.is_none() {
            return Err(PyTypeError::new_err(
                "_id_index can only be set to None; call _get_id_index() to populate.",
            ));
        }
        *self.id_index.lock().unwrap() = None;
        Ok(())
    }

    /// Return the cached `IdIndex`, creating and filling it on first
    /// call. Subsequent mutation methods refresh the same object in
    /// place so callers that hold the reference see fresh state.
    /// Mirrors `DirState._get_id_index`.
    #[pyo3(name = "_get_id_index")]
    fn get_id_index(&mut self, py: Python<'_>) -> PyResult<Py<IdIndex>> {
        if let Some(idx) = self.id_index.lock().unwrap().as_ref() {
            return Ok(idx.clone_ref(py));
        }
        let fresh = Py::new(py, IdIndex(bazaar::dirstate::IdIndex::new()))?;
        fresh.bind(py).borrow_mut().fill_from_state(self);
        *self.id_index.lock().unwrap() = Some(fresh.clone_ref(py));
        Ok(fresh)
    }

    /// Drop the cached `IdIndex` so the next `get_id_index` call
    /// rebuilds from scratch. Mirrors `self._id_index = None` on the
    /// Python side.
    fn clear_cached_id_index(&self) {
        *self.id_index.lock().unwrap() = None;
    }

    /// Serialise the in-memory state to the byte chunks that make up
    /// the on-disk dirstate file. Mirrors Python's
    /// `DirState.get_lines`: when both header and dirblocks are
    /// `IN_MEMORY_UNMODIFIED`, returns the on-disk bytes via the
    /// transport; otherwise serialises the in-memory state.
    fn get_lines<'py>(slf: Bound<'py, Self>, py: Python<'py>) -> PyResult<Bound<'py, PyList>> {
        use bazaar::dirstate::MemoryState;
        let unmodified = {
            let me = slf.borrow();
            matches!(me.inner.header_state, MemoryState::InMemoryUnmodified)
                && matches!(me.inner.dirblock_state, MemoryState::InMemoryUnmodified)
        };
        if unmodified {
            let data = {
                let me = slf.borrow();
                let mut t = me.transport.lock().unwrap();
                t.read_all().map_err(transport_err_to_py)?
            };
            let out = PyList::empty(py);
            let mut start = 0;
            for (i, b) in data.iter().enumerate() {
                if *b == b'\n' {
                    out.append(PyBytes::new(py, &data[start..=i]))?;
                    start = i + 1;
                }
            }
            if start < data.len() {
                out.append(PyBytes::new(py, &data[start..]))?;
            }
            return Ok(out);
        }
        read_dirblocks_if_needed(py, &slf)?;
        let me = slf.borrow();
        let lines = me.inner.get_lines();
        let items: Vec<Bound<PyBytes>> = lines.iter().map(|l| PyBytes::new(py, l)).collect();
        PyList::new(py, items)
    }

    /// Return all dirstate entries whose key `(dirname, basename)`
    /// matches `path_utf8`, across every file id. Mirrors Python's
    /// `DirState._entries_for_path`. Returns a snapshot list of
    /// entries in the `DirState.dirblocks` tuple shape.
    #[pyo3(name = "_entries_for_path")]
    fn entries_for_path<'py>(
        &self,
        py: Python<'py>,
        path_utf8: &[u8],
    ) -> PyResult<Bound<'py, PyList>> {
        let entries = self.inner.entries_for_path(path_utf8);
        let out = PyList::empty(py);
        for entry in entries {
            out.append(entry_to_py_tuple(py, entry)?)?;
        }
        Ok(out)
    }

    /// Walk the subtree rooted at `path_utf8` and return every live
    /// entry in `tree_index`. Mirrors Python's
    /// `DirState._iter_child_entries`. Returns a list of Python
    /// entries in the same tuple shape as `DirState.dirblocks`.
    ///
    /// The result is a snapshot: mutating returned entry tuples does
    /// NOT write back to the Rust-owned dirblocks. Callers that need
    /// in-place mutation must go through the (not-yet-exposed) Rust
    /// mutation methods.
    #[pyo3(name = "_iter_child_entries")]
    fn iter_child_entries(
        slf: Py<Self>,
        py: Python<'_>,
        tree_index: usize,
        path_utf8: &[u8],
    ) -> PyResult<ChildEntriesIter> {
        Ok(ChildEntriesIter {
            dirstate: slf.clone_ref(py),
            cursor: bazaar::dirstate::IterChildEntriesCursor::new(tree_index, path_utf8),
        })
    }

    /// Iterate every entry across every dirblock as
    /// `((dirname, basename, file_id), [tree_tuple, ...])` tuples in
    /// dirblock order.  Mirrors Python's `_iter_entries` generator.
    #[pyo3(name = "_iter_entries")]
    fn entries(slf: Bound<'_, Self>, py: Python<'_>) -> PyResult<AllEntriesIter> {
        read_dirblocks_if_needed(py, &slf)?;
        Ok(AllEntriesIter {
            dirstate: slf.unbind(),
            block_index: 0,
            entry_index: 0,
        })
    }

    /// Build a lazy `IterChanges` iterator.  Wraps the pure-crate
    /// `IterChangesIter` state machine; call repeatedly via
    /// `__next__` to drain one `DirstateInventoryChange`-shaped tuple
    /// at a time.  Mirrors `ProcessEntryPython.iter_changes` but
    /// without materialising the change list up front.
    #[pyo3(signature = (
        source_index,
        target_index,
        include_unchanged,
        want_unversioned,
        search_specific_files,
        supports_tree_reference,
        root_abspath,
    ))]
    fn iter_changes(
        slf: Py<Self>,
        py: Python<'_>,
        source_index: Option<usize>,
        target_index: usize,
        include_unchanged: bool,
        want_unversioned: bool,
        search_specific_files: &Bound<PyAny>,
        supports_tree_reference: bool,
        root_abspath: &Bound<PyAny>,
    ) -> PyResult<IterChanges> {
        let search: std::collections::HashSet<Vec<u8>> = collect_bytes_set(search_specific_files)?;
        let partial = !(search.len() == 1 && search.contains(&Vec::<u8>::new()));
        let root_abspath_bytes: Vec<u8> = root_abspath.extract()?;
        let pstate = bazaar::dirstate::ProcessEntryState {
            source_index,
            target_index,
            include_unchanged,
            want_unversioned,
            partial,
            supports_tree_reference,
            root_abspath: root_abspath_bytes,
            searched_specific_files: std::collections::HashSet::new(),
            search_specific_files: search,
            search_specific_file_parents: std::collections::HashSet::new(),
            searched_exact_paths: std::collections::HashSet::new(),
            seen_ids: std::collections::HashSet::new(),
            new_dirname_to_file_id: std::collections::HashMap::new(),
            old_dirname_to_file_id: std::collections::HashMap::new(),
            last_source_parent: None,
            last_target_parent: None,
        };
        Ok(IterChanges {
            dirstate: slf.clone_ref(py),
            iter: bazaar::dirstate::IterChangesIter::new(),
            pstate,
        })
    }

    fn __repr__(&self) -> String {
        format!("DirState({:?})", self.inner.filename)
    }

    /// Back-compat alias: `state._rs` used to be the Rust-side wrapper
    /// object held inside the Python `DirState`. Now the dirstate IS
    /// the Rust object, so `_rs` is the dirstate itself. Kept for
    /// callers that haven't been updated yet.
    #[getter]
    fn _rs(slf: Bound<'_, Self>) -> Py<Self> {
        slf.unbind()
    }

    /// Return the parent tree revision ids (post-header read).
    /// Mirrors Python's `DirState.get_parent_ids`.
    fn get_parent_ids<'py>(slf: Bound<'py, Self>, py: Python<'py>) -> PyResult<Bound<'py, PyList>> {
        Self::_read_header_if_needed(slf.clone())?;
        let me = slf.borrow();
        let items: Vec<Bound<PyBytes>> = me
            .inner
            .parents
            .iter()
            .map(|p| PyBytes::new(py, p))
            .collect();
        PyList::new(py, items)
    }

    /// Return ghost revision ids (post-header read).
    /// Mirrors Python's `DirState.get_ghosts`.
    #[pyo3(name = "get_ghosts")]
    fn get_ghosts_method<'py>(
        slf: Bound<'py, Self>,
        py: Python<'py>,
    ) -> PyResult<Bound<'py, PyList>> {
        Self::_read_header_if_needed(slf.clone())?;
        let me = slf.borrow();
        let items: Vec<Bound<PyBytes>> = me
            .inner
            .ghosts
            .iter()
            .map(|g| PyBytes::new(py, g))
            .collect();
        PyList::new(py, items)
    }

    /// Construct a NULL-delimited line for `entry`, the on-disk
    /// representation used by `get_lines`. Static method.
    #[staticmethod]
    #[pyo3(name = "_entry_to_line")]
    fn entry_to_line<'py>(
        py: Python<'py>,
        entry: &Bound<'py, PyAny>,
    ) -> PyResult<Bound<'py, PyBytes>> {
        let rust_entry = crate::dirstate_helpers::entry_from_py(entry)?;
        let bytes = bazaar::dirstate::entry_to_line(&rust_entry);
        Ok(PyBytes::new(py, &bytes))
    }

    /// Build a list of `NULL_PARENT_DETAILS` tuples, one per parent.
    /// Mirrors Python's `DirState._empty_parent_info`.
    #[pyo3(name = "_empty_parent_info")]
    fn empty_parent_info<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyList>> {
        let n = self.inner.num_present_parents();
        let out = PyList::empty(py);
        for _ in 0..n {
            out.append(Self::NULL_PARENT_DETAILS(py)?)?;
        }
        Ok(out)
    }

    /// Build the `((path_utf8, basename, kind, file_id, size,
    /// packed_stat, fingerprint), parents)` tuple Python's
    /// `_make_deleted_row` returns. Classmethod.
    #[classmethod]
    #[pyo3(name = "_make_deleted_row")]
    fn make_deleted_row<'py>(
        _cls: &Bound<'_, pyo3::types::PyType>,
        py: Python<'py>,
        fileid_utf8: &[u8],
        parents: &Bound<'py, PyAny>,
    ) -> PyResult<Bound<'py, PyTuple>> {
        let row = PyTuple::new(
            py,
            [
                PyBytes::new(py, b"/").into_any(),
                PyBytes::new(py, b"RECYCLED.BIN").into_any(),
                pyo3::types::PyString::new(py, "file").into_any(),
                PyBytes::new(py, fileid_utf8).into_any(),
                0i64.into_pyobject(py)?.into_any(),
                Self::NULLSTAT(py).into_any(),
                PyBytes::new(py, b"").into_any(),
            ],
        )?;
        PyTuple::new(py, [row.into_any(), parents.clone()])
    }

    /// Read in the dirblocks if not already loaded. Mirrors Python's
    /// `DirState._read_dirblocks_if_needed`.
    #[pyo3(name = "_read_dirblocks_if_needed")]
    fn read_dirblocks_if_needed_method(slf: Bound<'_, Self>, py: Python<'_>) -> PyResult<()> {
        read_dirblocks_if_needed(py, &slf)
    }

    /// Alias for `_read_header_if_needed`. Mirrors Python's
    /// `DirState._read_header`.
    #[pyo3(name = "_read_header")]
    fn read_header_alias(slf: Bound<'_, Self>) -> PyResult<()> {
        Self::_read_header_if_needed(slf)
    }

    /// Common setup shared by the three `_bisect*` methods: requires a
    /// lock, makes sure the header is loaded, asserts dirblocks are
    /// not yet materialised in memory, and returns the file size that
    /// the Rust bisector needs. Mirrors Python's
    /// `DirState._prepare_bisect`.
    #[pyo3(name = "_prepare_bisect")]
    fn prepare_bisect(slf: Bound<'_, Self>) -> PyResult<u64> {
        slf.borrow()._requires_lock()?;
        Self::_read_header_if_needed(slf.clone())?;
        let me = slf.borrow();
        if !matches!(
            me.inner.dirblock_state,
            bazaar::dirstate::MemoryState::NotInMemory
        ) {
            return Err(pyo3::exceptions::PyAssertionError::new_err(
                "bad dirblock state",
            ));
        }
        me._state_file_size()
    }

    /// Create a new dirstate on `path`. Equivalent to constructing,
    /// taking a write lock, populating with the empty-tree dirblocks,
    /// and saving. Mirrors Python's `DirState.initialize`.
    #[classmethod]
    #[pyo3(signature = (path, sha1_provider = None))]
    fn initialize(
        cls: &Bound<'_, pyo3::types::PyType>,
        py: Python<'_>,
        path: &Bound<PyAny>,
        sha1_provider: Option<&Bound<PyAny>>,
    ) -> PyResult<Py<Self>> {
        let result_bound = Self::on_file(cls, py, path, sha1_provider, 0, true, false)?;
        let bound = result_bound.bind(py);
        let _lock_result = Self::lock_write(bound.clone(), py)?;
        let outcome = (|| -> PyResult<()> {
            let empty_root = PyTuple::new(
                py,
                [
                    PyTuple::new(
                        py,
                        [
                            PyBytes::new(py, b"").into_any(),
                            PyBytes::new(py, b"").into_any(),
                            PyBytes::new(py, bazaar::inventory::ROOT_ID).into_any(),
                        ],
                    )?
                    .into_any(),
                    PyList::new(
                        py,
                        [PyTuple::new(
                            py,
                            [
                                PyBytes::new(py, b"d").into_any(),
                                PyBytes::new(py, b"").into_any(),
                                0i64.into_pyobject(py)?.into_any(),
                                pyo3::types::PyBool::new(py, false).to_owned().into_any(),
                                Self::NULLSTAT(py).into_any(),
                            ],
                        )?],
                    )?
                    .into_any(),
                ],
            )?;
            let blocks = PyList::new(
                py,
                [
                    PyTuple::new(
                        py,
                        [
                            PyBytes::new(py, b"").into_any(),
                            PyList::new(py, [empty_root])?.into_any(),
                        ],
                    )?,
                    PyTuple::new(
                        py,
                        [
                            PyBytes::new(py, b"").into_any(),
                            PyList::empty(py).into_any(),
                        ],
                    )?,
                ],
            )?;
            let empty_parents = PyList::empty(py);
            bound
                .borrow_mut()
                .set_data(py, empty_parents.as_any(), blocks.as_any())?;
            bound.borrow_mut().save()?;
            Ok(())
        })();
        if let Err(e) = outcome {
            bound.borrow().unlock()?;
            return Err(e);
        }
        Ok(result_bound)
    }

    /// Construct a DirState bound to the file at `path`, unlocked.
    /// Mirrors Python's `DirState.on_file`.
    #[classmethod]
    #[pyo3(signature = (
        path,
        sha1_provider = None,
        worth_saving_limit = 0,
        use_filesystem_for_exec = true,
        fdatasync = false,
    ))]
    fn on_file(
        _cls: &Bound<'_, pyo3::types::PyType>,
        py: Python<'_>,
        path: &Bound<PyAny>,
        sha1_provider: Option<&Bound<PyAny>>,
        worth_saving_limit: i64,
        use_filesystem_for_exec: bool,
        fdatasync: bool,
    ) -> PyResult<Py<Self>> {
        let result = Self::new(
            py,
            path,
            sha1_provider,
            worth_saving_limit,
            use_filesystem_for_exec,
            fdatasync,
        )?;
        Py::new(py, result)
    }
}

impl PyDirState {
    /// If a Python caller has previously fetched the cached IdIndex
    /// (via `_get_id_index`), refresh its contents in place so the
    /// held reference reflects the latest dirblock state. Called by
    /// every mutation method on PyDirState. No-op when nothing has
    /// been cached.
    fn refresh_cached_id_index(&mut self, py: Python<'_>) {
        let cached = self
            .id_index
            .lock()
            .unwrap()
            .as_ref()
            .map(|p| p.clone_ref(py));
        if let Some(idx) = cached {
            idx.bind(py).borrow_mut().fill_from_state(self);
        }
    }

    /// Shared error conversion for the three update_basis_apply_*
    /// methods: `Invalid` becomes `bzrformats.errors.InconsistentDelta`
    /// and also sets `changes_aborted` on the inner state (mirroring
    /// Python's `_raise_invalid`); `NotImplemented` and `Internal`
    /// become `NotImplementedError` and `AssertionError`.
    ///
    /// Defined in a plain `impl` block rather than `#[pymethods]`
    /// because `BasisApplyError` is not FFI-exposable.
    fn raise_basis_apply_error(
        &mut self,
        py: Python<'_>,
        err: bazaar::dirstate::BasisApplyError,
    ) -> PyErr {
        match err {
            bazaar::dirstate::BasisApplyError::Invalid {
                path,
                file_id,
                reason,
            } => {
                self.inner.changes_aborted = true;
                InconsistentDelta::new_err((
                    PyBytes::new(py, &path).unbind(),
                    PyBytes::new(py, &file_id).unbind(),
                    reason,
                ))
            }
            bazaar::dirstate::BasisApplyError::NotImplemented { reason } => {
                pyo3::exceptions::PyNotImplementedError::new_err(reason)
            }
            bazaar::dirstate::BasisApplyError::Internal { reason } => {
                pyo3::exceptions::PyAssertionError::new_err(reason)
            }
            bazaar::dirstate::BasisApplyError::NotVersioned { path } => {
                NotVersionedError::new_err((PyBytes::new(py, &path).unbind(), ""))
            }
            bazaar::dirstate::BasisApplyError::MismatchedEntryFileId {
                new_path,
                file_id,
                entry_debug,
            } => {
                self.inner.changes_aborted = true;
                InconsistentDelta::new_err((
                    PyBytes::new(py, &new_path).unbind(),
                    PyBytes::new(py, &file_id).unbind(),
                    format!("mismatched entry file_id {}", entry_debug),
                ))
            }
            bazaar::dirstate::BasisApplyError::NewPathWithoutEntry { new_path, file_id } => {
                self.inner.changes_aborted = true;
                InconsistentDelta::new_err((
                    PyBytes::new(py, &new_path).unbind(),
                    PyBytes::new(py, &file_id).unbind(),
                    "new_path with no entry",
                ))
            }
        }
    }
}

fn extract_fs_time(obj: &Bound<PyAny>) -> PyResult<u64> {
    if let Ok(u) = obj.extract::<u64>() {
        Ok(u)
    } else if let Ok(u) = obj.extract::<f64>() {
        Ok(u as u64)
    } else {
        Err(PyTypeError::new_err("Not a float or int"))
    }
}

#[pyfunction]
fn pack_stat<'a>(stat_result: &'a Bound<'a, PyAny>) -> PyResult<Bound<'a, PyBytes>> {
    let size = stat_result.getattr("st_size")?.extract::<u64>()?;
    let mtime = extract_fs_time(&stat_result.getattr("st_mtime")?)?;
    let ctime = extract_fs_time(&stat_result.getattr("st_ctime")?)?;
    let dev = stat_result.getattr("st_dev")?.extract::<u64>()?;
    let ino = stat_result.getattr("st_ino")?.extract::<u64>()?;
    let mode = stat_result.getattr("st_mode")?.extract::<u32>()?;
    let s = bazaar::dirstate::pack_stat(size, mtime, ctime, dev, ino, mode);
    Ok(PyBytes::new(stat_result.py(), s.as_bytes()))
}

#[pyfunction]
fn fields_per_entry(num_present_parents: usize) -> usize {
    bazaar::dirstate::fields_per_entry(num_present_parents)
}

#[pyfunction]
fn get_ghosts_line(py: Python, ghost_ids: Vec<Vec<u8>>) -> PyResult<Bound<PyBytes>> {
    let ghost_ids = ghost_ids
        .iter()
        .map(|x| x.as_slice())
        .collect::<Vec<&[u8]>>();
    let bs = bazaar::dirstate::get_ghosts_line(ghost_ids.as_slice());
    Ok(PyBytes::new(py, bs.as_slice()))
}

#[pyfunction]
fn get_parents_line(py: Python, parent_ids: Vec<Vec<u8>>) -> PyResult<Bound<PyBytes>> {
    let parent_ids = parent_ids
        .iter()
        .map(|x| x.as_slice())
        .collect::<Vec<&[u8]>>();
    let bs = bazaar::dirstate::get_parents_line(parent_ids.as_slice());
    Ok(PyBytes::new(py, bs.as_slice()))
}

/// Lazy iterator over the output of the pure-crate iter_changes walk.
///
/// The pyclass owns the walker state (`IterChangesIter`) and the
/// per-iter `ProcessEntryState`; it borrows the underlying `DirState`
/// via a `Py<PyDirState>` handle and re-acquires a mutable reference
/// to it on every `__next__` call.  Filesystem calls dispatch through
/// `PyFileTransport` wrapping `PyNone` — the transport's lstat /
/// readlink / list_dir all go through `os.*` directly, so no file
/// handle is required.
#[pyclass]
struct IterChanges {
    dirstate: Py<PyDirState>,
    iter: bazaar::dirstate::IterChangesIter,
    pstate: bazaar::dirstate::ProcessEntryState,
}

#[pymethods]
impl IterChanges {
    fn __iter__(slf: PyRef<Self>) -> PyRef<Self> {
        slf
    }

    fn __next__(mut slf: PyRefMut<Self>) -> PyResult<Option<Py<PyAny>>> {
        let py = slf.py();
        let transport = PyFileTransport::new(
            pyo3::types::PyNone::get(py).to_owned().into_any().unbind(),
            bazaar::dirstate::LockState::Read,
        );
        let dirstate = slf.dirstate.clone_ref(py);
        let IterChanges {
            iter: ref mut iter_state,
            ref mut pstate,
            ..
        } = *slf;
        let (result, dirstate_path) = {
            let mut state_ref = dirstate.borrow_mut(py);
            let path = state_ref.inner.filename.to_string_lossy().into_owned();
            let r = state_ref
                .inner
                .iter_changes_next(iter_state, pstate, &transport);
            (r, path)
        };
        match result {
            Ok(Some(change)) => {
                let tup = dirstate_change_to_pytuple(py, &change)?;
                // Construct the Rust-backed DirstateInventoryChange
                // directly rather than round-tripping through
                // bzrformats.dirstate to grab the class object.
                let instance = pyo3::Py::new(
                    py,
                    DirstateInventoryChange {
                        file_id: tup.get_item(0)?.unbind(),
                        path: tup.get_item(1)?.unbind(),
                        changed_content: tup.get_item(2)?.unbind(),
                        versioned: tup.get_item(3)?.unbind(),
                        parent_id: tup.get_item(4)?.unbind(),
                        name: tup.get_item(5)?.unbind(),
                        kind: tup.get_item(6)?.unbind(),
                        executable: tup.get_item(7)?.unbind(),
                        copied: tup.get_item(8)?.unbind(),
                    },
                )?;
                Ok(Some(instance.into_any()))
            }
            Ok(None) => Ok(None),
            Err(bazaar::dirstate::ProcessEntryError::DirstateCorrupt(msg)) => {
                Err(DirstateCorrupt::new_err((dirstate_path, msg)))
            }
            Err(bazaar::dirstate::ProcessEntryError::BadFileKind { path, mode }) => {
                Err(bad_file_kind_error(py, &path, mode))
            }
            Err(bazaar::dirstate::ProcessEntryError::Internal(msg)) => {
                Err(pyo3::exceptions::PyAssertionError::new_err(msg))
            }
        }
    }

    /// Read-only view of `search_specific_files` — the roots that
    /// still have to be walked.  Used by Python callers that want to
    /// peek at walker progress; mutation goes through the pure crate.
    #[getter]
    fn search_specific_files<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
        let out = pyo3::types::PySet::empty(py)?;
        for p in &self.pstate.search_specific_files {
            out.add(PyBytes::new(py, p))?;
        }
        Ok(out.into_any())
    }

    /// Read-only view of `searched_specific_files`.
    #[getter]
    fn searched_specific_files<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
        let out = pyo3::types::PySet::empty(py)?;
        for p in &self.pstate.searched_specific_files {
            out.add(PyBytes::new(py, p))?;
        }
        Ok(out.into_any())
    }

    /// Read-only view of `searched_exact_paths`.
    #[getter]
    fn searched_exact_paths<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
        let out = pyo3::types::PySet::empty(py)?;
        for p in &self.pstate.searched_exact_paths {
            out.add(PyBytes::new(py, p))?;
        }
        Ok(out.into_any())
    }

    /// Read-only view of `search_specific_file_parents`.
    #[getter]
    fn search_specific_file_parents<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
        let out = pyo3::types::PySet::empty(py)?;
        for p in &self.pstate.search_specific_file_parents {
            out.add(PyBytes::new(py, p))?;
        }
        Ok(out.into_any())
    }

    /// Read-only view of `seen_ids`.
    #[getter]
    fn seen_ids<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
        let out = pyo3::types::PySet::empty(py)?;
        for p in &self.pstate.seen_ids {
            out.add(PyBytes::new(py, p))?;
        }
        Ok(out.into_any())
    }
}

/// Iterator returned by `DirState._iter_child_entries`. Drives the
/// pure-crate [`IterChildEntriesCursor`] one entry at a time, building
/// the `(key, [tree, ...])` tuple on demand.
#[pyclass]
struct ChildEntriesIter {
    dirstate: Py<PyDirState>,
    cursor: bazaar::dirstate::IterChildEntriesCursor,
}

#[pymethods]
impl ChildEntriesIter {
    fn __iter__(slf: PyRef<Self>) -> PyRef<Self> {
        slf
    }

    fn __next__<'py>(&mut self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyTuple>>> {
        let state = self.dirstate.borrow(py);
        match self.cursor.next_entry(&state.inner) {
            Some(entry) => Ok(Some(entry_to_py_tuple(py, entry)?)),
            None => Ok(None),
        }
    }
}

/// Iterator returned by `DirState._iter_entries`. Walks every entry in
/// dirblock order via an index cursor, building each `(key, [tree, ...])`
/// tuple on demand.
#[pyclass]
struct AllEntriesIter {
    dirstate: Py<PyDirState>,
    block_index: usize,
    entry_index: usize,
}

#[pymethods]
impl AllEntriesIter {
    fn __iter__(slf: PyRef<Self>) -> PyRef<Self> {
        slf
    }

    fn __next__<'py>(&mut self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyTuple>>> {
        let state = self.dirstate.borrow(py);
        loop {
            if self.block_index >= state.inner.dirblock_count() {
                return Ok(None);
            }
            if self.entry_index >= state.inner.dirblock_entry_count(self.block_index) {
                self.block_index += 1;
                self.entry_index = 0;
                continue;
            }
            let entry = state
                .inner
                .entry_at(self.block_index, self.entry_index)
                .expect("index checked above");
            self.entry_index += 1;
            return Ok(Some(entry_to_py_tuple(py, entry)?));
        }
    }
}

#[pyclass]
struct IdIndex(bazaar::dirstate::IdIndex);

#[pymethods]
impl IdIndex {
    #[new]
    fn new() -> Self {
        IdIndex(bazaar::dirstate::IdIndex::new())
    }

    fn add(&mut self, entry: (Vec<u8>, Vec<u8>, FileId)) -> PyResult<()> {
        self.0.add((&entry.0, &entry.1, &entry.2));
        Ok(())
    }

    fn remove(&mut self, entry: (Vec<u8>, Vec<u8>, FileId)) -> PyResult<()> {
        self.0.remove((&entry.0, &entry.1, &entry.2));
        Ok(())
    }

    fn clear(&mut self) {
        self.0.clear();
    }

    /// Replace the contents of this IdIndex with the id_index Rust
    /// already maintains for `state`.  Faster than walking dirblocks
    /// from Python because it avoids marshalling the whole tree.
    fn fill_from_state(&mut self, state: &mut PyDirState) {
        self.0.clear();
        let rust_index = state.inner.get_or_build_id_index();
        for (dn, bn, fid) in rust_index.iter_all() {
            self.0.add((dn, bn, fid));
        }
    }

    fn get<'a>(
        &self,
        py: Python<'a>,
        file_id: FileId,
    ) -> PyResult<Vec<(Bound<'a, PyBytes>, Bound<'a, PyBytes>, Bound<'a, PyBytes>)>> {
        let ret = self.0.get(&file_id);
        ret.iter()
            .map(|(a, b, c)| {
                Ok((
                    PyBytes::new(py, a),
                    PyBytes::new(py, b),
                    c.into_pyobject(py)?,
                ))
            })
            .collect::<PyResult<Vec<_>>>()
    }

    fn iter_all<'py>(
        &self,
        py: Python<'py>,
    ) -> PyResult<
        Vec<(
            Bound<'py, PyBytes>,
            Bound<'py, PyBytes>,
            Bound<'py, PyBytes>,
        )>,
    > {
        let ret = self.0.iter_all();
        ret.map(|(a, b, c)| {
            Ok((
                PyBytes::new(py, a),
                PyBytes::new(py, b),
                c.into_pyobject(py)?,
            ))
        })
        .collect::<PyResult<Vec<_>>>()
    }

    fn file_ids<'a>(&self, py: Python<'a>) -> PyResult<Vec<Bound<'a, PyBytes>>> {
        self.0.file_ids().map(|x| x.into_pyobject(py)).collect()
    }
}

/// Change record produced by [`IterChanges`]. Mirrors the
/// Python-side `bzrformats.dirstate.DirstateInventoryChange` class:
/// a 9-field data carrier with a handful of derived predicates used
/// by callers that want to compare/transform tree changes without
/// walking the underlying entry tuples themselves.
#[pyclass(module = "bzrformats._bzr_rs.dirstate", subclass)]
struct DirstateInventoryChange {
    #[pyo3(get, set)]
    file_id: Py<PyAny>,
    #[pyo3(get, set)]
    path: Py<PyAny>,
    #[pyo3(get, set)]
    changed_content: Py<PyAny>,
    #[pyo3(get, set)]
    versioned: Py<PyAny>,
    #[pyo3(get, set)]
    parent_id: Py<PyAny>,
    #[pyo3(get, set)]
    name: Py<PyAny>,
    #[pyo3(get, set)]
    kind: Py<PyAny>,
    #[pyo3(get, set)]
    executable: Py<PyAny>,
    #[pyo3(get, set)]
    copied: Py<PyAny>,
}

#[pymethods]
impl DirstateInventoryChange {
    #[new]
    #[pyo3(signature = (
        file_id,
        path,
        changed_content,
        versioned,
        parent_id,
        name,
        kind,
        executable,
        copied = None,
    ))]
    #[allow(clippy::too_many_arguments)]
    fn new(
        py: Python<'_>,
        file_id: Py<PyAny>,
        path: Py<PyAny>,
        changed_content: Py<PyAny>,
        versioned: Py<PyAny>,
        parent_id: Py<PyAny>,
        name: Py<PyAny>,
        kind: Py<PyAny>,
        executable: Py<PyAny>,
        copied: Option<Py<PyAny>>,
    ) -> Self {
        Self {
            file_id,
            path,
            changed_content,
            versioned,
            parent_id,
            name,
            kind,
            executable,
            copied: copied.unwrap_or_else(|| {
                pyo3::types::PyBool::new(py, false)
                    .to_owned()
                    .unbind()
                    .into_any()
            }),
        }
    }

    /// True iff the file is versioned in both trees and the executable
    /// bit changed. Used by callers that want to ignore non-meta-only
    /// changes.
    fn meta_modified(&self, py: Python<'_>) -> PyResult<bool> {
        // Mirror Python: `self.versioned == (True, True) and
        // self.executable[0] != self.executable[1]`.
        let versioned = self.versioned.bind(py);
        let both_versioned = versioned.eq(PyTuple::new(
            py,
            [
                pyo3::types::PyBool::new(py, true).to_owned(),
                pyo3::types::PyBool::new(py, true).to_owned(),
            ],
        )?)?;
        if !both_versioned {
            return Ok(false);
        }
        let exec = self.executable.bind(py);
        let exec0 = exec.get_item(0)?;
        let exec1 = exec.get_item(1)?;
        Ok(!exec0.eq(exec1)?)
    }

    fn is_reparented(&self, py: Python<'_>) -> PyResult<bool> {
        let parent_id = self.parent_id.bind(py);
        let p0 = parent_id.get_item(0)?;
        let p1 = parent_id.get_item(1)?;
        Ok(!p0.eq(p1)?)
    }

    #[getter]
    fn renamed(&self, py: Python<'_>) -> PyResult<bool> {
        // not copied and None not in name and None not in parent_id
        // and (name[0] != name[1] or parent_id[0] != parent_id[1])
        let copied = self.copied.bind(py).is_truthy()?;
        if copied {
            return Ok(false);
        }
        let name = self.name.bind(py);
        let parent_id = self.parent_id.bind(py);
        if name.contains(py.None())? {
            return Ok(false);
        }
        if parent_id.contains(py.None())? {
            return Ok(false);
        }
        let names_differ = !name.get_item(0)?.eq(name.get_item(1)?)?;
        let parents_differ = !parent_id.get_item(0)?.eq(parent_id.get_item(1)?)?;
        Ok(names_differ || parents_differ)
    }

    /// Return a fresh DirstateInventoryChange with all "new" sides
    /// of the (old, new) tuples set to None and `copied=False`.
    fn discard_new<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, DirstateInventoryChange>> {
        fn old_then_none<'py>(py: Python<'py>, tup: &Py<PyAny>) -> PyResult<Bound<'py, PyTuple>> {
            let bound = tup.bind(py);
            PyTuple::new(py, [bound.get_item(0)?, py.None().into_bound(py)])
        }
        let path = old_then_none(py, &self.path)?;
        let versioned = old_then_none(py, &self.versioned)?;
        let parent_id = old_then_none(py, &self.parent_id)?;
        let name = old_then_none(py, &self.name)?;
        let kind = old_then_none(py, &self.kind)?;
        let executable = old_then_none(py, &self.executable)?;
        Bound::new(
            py,
            DirstateInventoryChange {
                file_id: self.file_id.clone_ref(py),
                path: path.into_any().unbind(),
                changed_content: self.changed_content.clone_ref(py),
                versioned: versioned.into_any().unbind(),
                parent_id: parent_id.into_any().unbind(),
                name: name.into_any().unbind(),
                kind: kind.into_any().unbind(),
                executable: executable.into_any().unbind(),
                copied: pyo3::types::PyBool::new(py, false)
                    .to_owned()
                    .unbind()
                    .into_any(),
            },
        )
    }

    fn _as_tuple<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyTuple>> {
        PyTuple::new(
            py,
            [
                self.file_id.bind(py).clone(),
                self.path.bind(py).clone(),
                self.changed_content.bind(py).clone(),
                self.versioned.bind(py).clone(),
                self.parent_id.bind(py).clone(),
                self.name.bind(py).clone(),
                self.kind.bind(py).clone(),
                self.executable.bind(py).clone(),
                self.copied.bind(py).clone(),
            ],
        )
    }

    fn __repr__(&self, py: Python<'_>) -> PyResult<String> {
        let tup = self._as_tuple(py)?;
        let tup_repr = tup.repr()?;
        Ok(format!("DirstateInventoryChange{}", tup_repr))
    }

    fn __getitem__<'py>(&self, py: Python<'py>, index: isize) -> PyResult<Bound<'py, PyAny>> {
        let tup = self._as_tuple(py)?;
        Ok(tup.get_item(if index < 0 {
            (tup.len() as isize + index) as usize
        } else {
            index as usize
        })?)
    }

    fn __eq__(
        slf: PyRef<'_, Self>,
        py: Python<'_>,
        other: Bound<'_, PyAny>,
    ) -> PyResult<Py<PyAny>> {
        if let Ok(other_ref) = other.downcast::<DirstateInventoryChange>() {
            let a = slf._as_tuple(py)?;
            let b = other_ref.borrow()._as_tuple(py)?;
            return Ok(a.eq(b)?.into_pyobject(py)?.to_owned().into_any().unbind());
        }
        if other.downcast::<PyTuple>().is_ok() {
            let a = slf._as_tuple(py)?;
            return Ok(a
                .eq(other)?
                .into_pyobject(py)?
                .to_owned()
                .into_any()
                .unbind());
        }
        Ok(py.NotImplemented())
    }
}

#[pyfunction]
fn inv_entry_to_details<'a>(
    py: Python<'a>,
    e: &'a crate::inventory::InventoryEntry,
) -> (
    Bound<'a, PyBytes>,
    Bound<'a, PyBytes>,
    u64,
    bool,
    Bound<'a, PyBytes>,
) {
    let ret = bazaar::dirstate::inv_entry_to_details(&e.0);

    (
        PyBytes::new(py, &[ret.0.to_minikind()]),
        PyBytes::new(py, ret.1.as_slice()),
        ret.2,
        ret.3,
        PyBytes::new(py, ret.4.as_slice()),
    )
}

#[pyfunction]
fn get_output_lines(py: Python<'_>, lines: Vec<Vec<u8>>) -> Vec<Bound<'_, PyBytes>> {
    let lines = lines.iter().map(|x| x.as_slice()).collect::<Vec<&[u8]>>();
    bazaar::dirstate::get_output_lines(lines)
        .into_iter()
        .map(|x| PyBytes::new(py, x.as_slice()))
        .collect()
}

/// Helpers for the dirstate module.
pub fn _dirstate_rs(py: Python) -> PyResult<Bound<PyModule>> {
    let m = PyModule::new(py, "dirstate")?;
    m.add_wrapped(wrap_pyfunction!(lt_by_dirs))?;
    m.add_wrapped(wrap_pyfunction!(bisect_path_left))?;
    m.add_wrapped(wrap_pyfunction!(bisect_path_right))?;
    m.add_wrapped(wrap_pyfunction!(lt_path_by_dirblock))?;
    m.add_wrapped(wrap_pyfunction!(bisect_dirblock))?;
    m.add_class::<SHA1Provider>()?;
    m.add_class::<PyDefaultSHA1Provider>()?;
    m.add_wrapped(wrap_pyfunction!(pack_stat))?;
    m.add_wrapped(wrap_pyfunction!(fields_per_entry))?;
    m.add_wrapped(wrap_pyfunction!(get_ghosts_line))?;
    m.add_wrapped(wrap_pyfunction!(get_parents_line))?;
    m.add_class::<IdIndex>()?;
    m.add_class::<PyDirState>()?;
    m.add_class::<IterChanges>()?;
    m.add_class::<DirstateInventoryChange>()?;
    m.add_wrapped(wrap_pyfunction!(inv_entry_to_details))?;
    m.add_wrapped(wrap_pyfunction!(get_output_lines))?;

    // Register dirstate helper functions (_read_dirblocks, entry_to_line).
    crate::dirstate_helpers::register(&m)?;

    Ok(m)
}
