//! [`ReadonlyRepo`] - a repository view pinned to a single `OperationId`.
//!
//! Cheap to clone (every field is `Arc`-wrapped). Loaned / cloned into a
//! [`Transaction`] via [`ReadonlyRepo::start_transaction`]; after a
//! `Transaction::commit`, a new `ReadonlyRepo` pinned to the next op is
//! returned.
//!
//! [`Transaction`]: crate::repo::transaction::Transaction

use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use std::time::{SystemTime, UNIX_EPOCH};

use ipld_core::ipld::Ipld;

use crate::codec::{from_canonical_bytes, hash_to_cid};
use crate::error::{Error, RepoError, StoreError};
use crate::id::{Cid, EdgeId, NodeId};
use crate::objects::node::Embedding;
use crate::objects::{
    Commit, Edge, EmbeddingBucket, Node, Operation, RefTarget, SparseBucket, View,
};
use crate::prolly::{self, ProllyKey};
use crate::sparse::SparseEmbed;
use crate::store::{Blockstore, OpHeadsStore};

use super::transaction::Transaction;

/// Current microseconds since Unix epoch. Used throughout the repo
/// layer for timestamps on new Operations.
pub(crate) fn now_micros() -> u64 {
    #[cfg(target_arch = "wasm32")]
    {
        // SystemTime is not available in the browser; use JS Date.now() (ms → µs).
        (js_sys::Date::now() * 1_000.0) as u64
    }
    #[cfg(not(target_arch = "wasm32"))]
    {
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_micros() as u64
    }
}

/// A view of the repository pinned to a single `OperationId`.
///
/// `ReadonlyRepo` does not mutate state. To make changes, call
/// [`start_transaction`] and then [`Transaction::commit`] - which returns
/// a fresh `ReadonlyRepo` pinned to the new op.
///
/// All fields are behind `Arc`, so `clone()` is cheap. Sharing across
/// threads is safe.
///
/// [`start_transaction`]: ReadonlyRepo::start_transaction
#[derive(Clone)]
pub struct ReadonlyRepo {
    pub(crate) blockstore: Arc<dyn Blockstore>,
    pub(crate) op_heads: Arc<dyn OpHeadsStore>,
    pub(crate) op_id: Cid,
    pub(crate) op: Arc<Operation>,
    pub(crate) view: Arc<View>,
    /// Head commit of the current view. `None` for a freshly-initialized
    /// repository (root-View exception, SPEC §4.6 / §7.5).
    pub(crate) commit: Option<Arc<Commit>>,
}

impl ReadonlyRepo {
    // ---------------- Construction ----------------

    /// Initialize a fresh repository per SPEC §7.5.
    ///
    /// Writes one root View (empty heads, empty refs) and one root
    /// Operation into the blockstore, registers the op as the sole
    /// op-head, and returns a `ReadonlyRepo` pinned to that op.
    ///
    /// # Errors
    ///
    /// Returns a store or codec error if blockstore writes fail.
    pub fn init(
        blockstore: Arc<dyn Blockstore>,
        op_heads: Arc<dyn OpHeadsStore>,
    ) -> Result<Self, Error> {
        let now = now_micros();

        // Root view: empty heads, empty refs (SPEC §7.5).
        let root_view = View::new();
        let (view_bytes, view_cid) = hash_to_cid(&root_view)?;
        blockstore.put(view_cid.clone(), view_bytes)?;

        // Root operation: parents=[], description="init".
        let root_op = Operation::new(view_cid, "", now, "init");
        let (op_bytes, op_cid) = hash_to_cid(&root_op)?;
        blockstore.put(op_cid.clone(), op_bytes)?;

        // Advance op-heads: root_op with no supersedes.
        op_heads.update(op_cid.clone(), &[])?;

        Ok(Self {
            blockstore,
            op_heads,
            op_id: op_cid,
            op: Arc::new(root_op),
            view: Arc::new(root_view),
            commit: None,
        })
    }

    /// Open an existing repository pinned to the current op-head.
    ///
    /// If the op-heads store has more than one current head (concurrent
    /// writers landed against the same base), the 3-way merge from
    /// [`crate::repo::merge`] runs transparently: it finds the op-DAG
    /// common ancestor, 3-way merges each head's View (emitting
    /// [`RefTarget::Conflicted`] for divergent refs), writes a synthetic
    /// merge Operation, and advances op-heads. The returned
    /// `ReadonlyRepo` is pinned to that merge op.
    ///
    /// # Errors
    ///
    /// - [`RepoError::Uninitialized`] if the op-heads store is empty
    /// - call [`ReadonlyRepo::init`] first.
    /// - [`RepoError::NoCommonAncestor`] if the op-DAG is malformed.
    /// - Store / codec errors if loading objects fails.
    pub fn open(
        blockstore: Arc<dyn Blockstore>,
        op_heads: Arc<dyn OpHeadsStore>,
    ) -> Result<Self, Error> {
        let heads = op_heads.current()?;
        match heads.len() {
            0 => Err(RepoError::Uninitialized.into()),
            1 => Self::load_at(blockstore, op_heads, heads.into_iter().next().unwrap()),
            _ => {
                let merge_cid = super::merge::merge_op_heads(&blockstore, &op_heads, heads)?;
                Self::load_at(blockstore, op_heads, merge_cid)
            }
        }
    }

    /// Load a repository view pinned to a specific `OperationId`.
    ///
    /// Does not consult the op-heads store. Used internally by
    /// [`open`] and [`Transaction::commit`].
    ///
    /// # Errors
    ///
    /// Store / codec errors if loading objects fails.
    ///
    /// [`open`]: Self::open
    /// [`Transaction::commit`]: crate::repo::Transaction::commit
    pub fn load_at(
        blockstore: Arc<dyn Blockstore>,
        op_heads: Arc<dyn OpHeadsStore>,
        op_id: Cid,
    ) -> Result<Self, Error> {
        let op: Operation = decode_from_store(&*blockstore, &op_id)?;
        let view: View = decode_from_store(&*blockstore, &op.view)?;
        let commit = if let Some(head) = view.heads.first() {
            let c: Commit = decode_from_store(&*blockstore, head)?;
            Some(Arc::new(c))
        } else {
            None
        };
        Ok(Self {
            blockstore,
            op_heads,
            op_id,
            op: Arc::new(op),
            view: Arc::new(view),
            commit,
        })
    }

    // ---------------- Accessors ----------------

    /// The CID of the Operation this view is pinned to.
    #[must_use]
    pub const fn op_id(&self) -> &Cid {
        &self.op_id
    }

    /// The Operation this view is pinned to.
    #[must_use]
    pub fn operation(&self) -> &Operation {
        &self.op
    }

    /// The View snapshotted by the current Operation.
    #[must_use]
    pub fn view(&self) -> &View {
        &self.view
    }

    /// The head Commit of the current view. `None` on a freshly-
    /// initialized repository that hasn't yet received any commits.
    #[must_use]
    pub fn head_commit(&self) -> Option<&Commit> {
        self.commit.as_deref()
    }

    /// Access the underlying blockstore (borrowed `Arc`).
    #[must_use]
    pub fn blockstore(&self) -> &Arc<dyn Blockstore> {
        &self.blockstore
    }

    /// Access the underlying op-heads store (borrowed `Arc`).
    #[must_use]
    pub fn op_heads_store(&self) -> &Arc<dyn OpHeadsStore> {
        &self.op_heads
    }

    // ---------------- Read operations ----------------

    /// Look up a node by its stable [`NodeId`] in the current commit's
    /// node tree. Returns `None` if absent or if the repository has no
    /// commits yet.
    ///
    /// # Errors
    ///
    /// Store or codec errors while walking the Prolly tree.
    pub fn lookup_node(&self, id: &NodeId) -> Result<Option<Node>, Error> {
        let Some(commit) = self.commit.as_ref() else {
            return Ok(None);
        };
        let key = ProllyKey::from(*id);
        match prolly::lookup(&*self.blockstore, &commit.nodes, &key)? {
            Some(node_cid) => {
                let node: Node = decode_from_store(&*self.blockstore, &node_cid)?;
                Ok(Some(node))
            }
            None => Ok(None),
        }
    }

    /// Look up an edge by its stable [`EdgeId`] in the current commit's
    /// edge tree. Returns `None` if absent or if the repository has no
    /// commits yet.
    ///
    /// # Errors
    ///
    /// Store or codec errors while walking the Prolly tree.
    pub fn lookup_edge(&self, id: &EdgeId) -> Result<Option<Edge>, Error> {
        let Some(commit) = self.commit.as_ref() else {
            return Ok(None);
        };
        let key = ProllyKey::from(*id);
        match prolly::lookup(&*self.blockstore, &commit.edges, &key)? {
            Some(edge_cid) => {
                let edge: Edge = decode_from_store(&*self.blockstore, &edge_cid)?;
                Ok(Some(edge))
            }
            None => Ok(None),
        }
    }

    /// Look up the embedding for a node by its content-addressed
    /// `NodeCid` and a model identifier, walking the
    /// [`Commit::embeddings`](crate::objects::Commit::embeddings)
    /// Prolly sidecar. Returns `None` when:
    ///
    /// - the repo has no commits yet,
    /// - the head commit has no embedding sidecar (`embeddings = None`),
    /// - the sidecar tree has no entry for this `NodeCid`, or
    /// - the bucket exists but does not carry a vector under the
    /// requested `model` string.
    ///
    /// The Prolly key is derived via the same helper
    /// (`embedding_key_for_node_cid`) the write side uses, so a
    /// `Transaction::set_embedding` write and a subsequent
    /// `embedding_for` read are guaranteed to agree on the bucket
    /// location.
    ///
    /// # Why not on `Node`?
    ///
    /// The same trade documented on
    /// [`Commit::embeddings`](crate::objects::Commit::embeddings):
    /// dense vector bytes drift in the last bit across ORT thread
    /// counts, so storing them on the `Node` would couple `NodeCid`
    /// to thread count. The sidecar separates identity (Node) from
    /// derived bytes (Embedding) so `NodeCid` stays stable.
    ///
    /// # Errors
    ///
    /// Store or codec errors while walking the Prolly tree or
    /// decoding the bucket. A missing key is `Ok(None)`, not an error.
    pub fn embedding_for(&self, node_cid: &Cid, model: &str) -> Result<Option<Embedding>, Error> {
        let Some(commit) = self.commit.as_ref() else {
            return Ok(None);
        };
        let Some(embeddings_root) = commit.embeddings.as_ref() else {
            return Ok(None);
        };
        let key = super::transaction::embedding_key_for_node_cid(node_cid);
        let Some(bucket_cid) = prolly::lookup(&*self.blockstore, embeddings_root, &key)? else {
            return Ok(None);
        };
        let bucket: EmbeddingBucket = decode_from_store(&*self.blockstore, &bucket_cid)?;
        Ok(bucket.get(model).cloned())
    }

    /// List every model identifier stored in the embedding sidecar for a
    /// node, identified by its content-addressed `NodeCid`.
    ///
    /// Returns an empty `Vec` (not an error) when:
    ///
    /// - the repo has no commits yet,
    /// - the head commit has no embedding sidecar (`embeddings = None`), or
    /// - the sidecar has no bucket for this `NodeCid`.
    ///
    /// When a bucket exists, the model strings are returned in the bucket's
    /// canonical lexicographic order (the same order they were sorted into
    /// at write time by [`EmbeddingBucket::upsert`]).
    ///
    /// # Errors
    ///
    /// Store or codec errors while walking the Prolly tree or decoding the
    /// bucket. A missing key is `Ok(vec![])`, not an error.
    pub fn embedding_models_for(&self, node_cid: &Cid) -> Result<Vec<String>, Error> {
        let Some(commit) = self.commit.as_ref() else {
            return Ok(Vec::new());
        };
        let Some(embeddings_root) = commit.embeddings.as_ref() else {
            return Ok(Vec::new());
        };
        let key = super::transaction::embedding_key_for_node_cid(node_cid);
        let Some(bucket_cid) = prolly::lookup(&*self.blockstore, embeddings_root, &key)? else {
            return Ok(Vec::new());
        };
        let bucket: EmbeddingBucket = decode_from_store(&*self.blockstore, &bucket_cid)?;
        Ok(bucket.entries.iter().map(|e| e.model.clone()).collect())
    }

    /// Look up the sparse embedding for a node by its content-addressed
    /// `NodeCid` and a vocabulary identifier, walking the
    /// [`Commit::sparse`](crate::objects::Commit::sparse) Prolly sidecar.
    /// Returns `None` when:
    ///
    /// - the repo has no commits yet,
    /// - the head commit has no sparse sidecar (`sparse = None`),
    /// - the sidecar tree has no entry for this `NodeCid`, or
    /// - the bucket exists but does not carry a vector for the
    /// requested `vocab_id`.
    ///
    /// # Errors
    ///
    /// Store or codec errors while walking the Prolly tree or decoding
    /// the bucket. A missing key is `Ok(None)`, not an error.
    pub fn sparse_for(&self, node_cid: &Cid, vocab_id: &str) -> Result<Option<SparseEmbed>, Error> {
        let Some(commit) = self.commit.as_ref() else {
            return Ok(None);
        };
        let Some(sparse_root) = commit.sparse.as_ref() else {
            return Ok(None);
        };
        let key = super::transaction::sparse_key_for_node_cid(node_cid);
        let Some(bucket_cid) = prolly::lookup(&*self.blockstore, sparse_root, &key)? else {
            return Ok(None);
        };
        let bucket: SparseBucket = decode_from_store(&*self.blockstore, &bucket_cid)?;
        Ok(bucket.get(vocab_id).cloned())
    }

    /// List every vocabulary identifier stored in the sparse sidecar for a
    /// node, identified by its content-addressed `NodeCid`.
    ///
    /// Returns an empty `Vec` (not an error) when:
    ///
    /// - the repo has no commits yet,
    /// - the head commit has no sparse sidecar (`sparse = None`), or
    /// - the sidecar has no bucket for this `NodeCid`.
    ///
    /// When a bucket exists, the vocab strings are returned in lexicographic
    /// order (the same order they were sorted into at write time by
    /// [`SparseBucket::upsert`]).
    ///
    /// # Errors
    ///
    /// Store or codec errors while walking the Prolly tree or decoding the
    /// bucket. A missing key is `Ok(vec![])`, not an error.
    pub fn sparse_vocabs_for(&self, node_cid: &Cid) -> Result<Vec<String>, Error> {
        let Some(commit) = self.commit.as_ref() else {
            return Ok(Vec::new());
        };
        let Some(sparse_root) = commit.sparse.as_ref() else {
            return Ok(Vec::new());
        };
        let key = super::transaction::sparse_key_for_node_cid(node_cid);
        let Some(bucket_cid) = prolly::lookup(&*self.blockstore, sparse_root, &key)? else {
            return Ok(Vec::new());
        };
        let bucket: SparseBucket = decode_from_store(&*self.blockstore, &bucket_cid)?;
        Ok(bucket.entries.iter().map(|e| e.vocab_id.clone()).collect())
    }

    /// All outgoing edges from `src` in the current commit, optionally
    /// filtered by edge-type label. Returns an empty vec if the node
    /// has no adjacency bucket (no authored out-edges), or if the repo
    /// has no commits yet.
    ///
    /// Used by graph-aware retrieval (`Retriever::with_graph_expand`)
    /// to expand a seed set via 1-hop neighborhood traversal.
    ///
    /// # Errors
    ///
    /// Store or codec errors while walking the adjacency index or
    /// decoding Edge blocks.
    pub fn outgoing_edges(
        &self,
        src: &NodeId,
        etype_filter: Option<&[&str]>,
    ) -> Result<Vec<Edge>, Error> {
        let Some(commit) = self.commit.as_ref() else {
            return Ok(Vec::new());
        };
        let Some(indexes_cid) = commit.indexes.as_ref() else {
            return Ok(Vec::new());
        };
        let indexes: crate::objects::IndexSet = decode_from_store(&*self.blockstore, indexes_cid)?;
        let Some(adj_root) = &indexes.outgoing else {
            return Ok(Vec::new());
        };
        let key = ProllyKey::from(*src);
        let Some(bucket_cid) = prolly::lookup(&*self.blockstore, adj_root, &key)? else {
            return Ok(Vec::new());
        };
        let bucket: crate::objects::AdjacencyBucket =
            decode_from_store(&*self.blockstore, &bucket_cid)?;
        let mut out = Vec::new();
        for ae in &bucket.edges {
            if let Some(want) = etype_filter
                && !want.contains(&ae.label.as_str())
            {
                continue;
            }
            let edge: Edge = decode_from_store(&*self.blockstore, &ae.edge)?;
            out.push(edge);
        }
        Ok(out)
    }

    /// All incoming edges pointing at `dst` in the current commit,
    /// optionally filtered by edge-type label. Returns an empty vec if
    /// the node has no incoming-adjacency bucket, if the commit's
    /// `IndexSet` has no `incoming` tree (pre-0.3 repos), or if the
    /// repo has no commits yet.
    ///
    /// Symmetric mirror of [`Self::outgoing_edges`]. Use this from
    /// agent-side callers that want "who points at this node" without
    /// constructing a full [`crate::index::Query`].
    ///
    /// # Errors
    ///
    /// Store or codec errors while walking the incoming-adjacency
    /// index or decoding Edge blocks.
    pub fn incoming_edges(
        &self,
        dst: &NodeId,
        etype_filter: Option<&[&str]>,
    ) -> Result<Vec<Edge>, Error> {
        self.incoming_edges_capped(
            dst,
            etype_filter,
            crate::index::Query::DEFAULT_ADJACENCY_CAP,
        )
    }

    /// Explicit-cap variant of [`Self::incoming_edges`]. Use this
    /// when a caller is prepared to handle truncation (e.g. an MCP
    /// tool that streams the bucket and renders its own
    /// "clipped at N" marker). Default [`Self::incoming_edges`]
    /// applies [`crate::index::Query::DEFAULT_ADJACENCY_CAP`] so a single
    /// high-fan-in dst can't `DoS` the agent-side caller.
    ///
    /// # Errors
    ///
    /// Store or codec errors while walking the incoming-adjacency
    /// index or decoding Edge blocks.
    pub fn incoming_edges_capped(
        &self,
        dst: &NodeId,
        etype_filter: Option<&[&str]>,
        cap: usize,
    ) -> Result<Vec<Edge>, Error> {
        let Some(commit) = self.commit.as_ref() else {
            return Ok(Vec::new());
        };
        let Some(indexes_cid) = commit.indexes.as_ref() else {
            return Ok(Vec::new());
        };
        let indexes: crate::objects::IndexSet = decode_from_store(&*self.blockstore, indexes_cid)?;
        let Some(inc_root) = &indexes.incoming else {
            return Ok(Vec::new());
        };
        let key = ProllyKey::from(*dst);
        let Some(bucket_cid) = prolly::lookup(&*self.blockstore, inc_root, &key)? else {
            return Ok(Vec::new());
        };
        let bucket: crate::objects::IncomingAdjacencyBucket =
            decode_from_store(&*self.blockstore, &bucket_cid)?;
        let mut out = Vec::with_capacity(bucket.edges.len().min(cap));
        for ae in &bucket.edges {
            if out.len() >= cap {
                break;
            }
            if let Some(want) = etype_filter
                && !want.contains(&ae.label.as_str())
            {
                continue;
            }
            let edge: Edge = decode_from_store(&*self.blockstore, &ae.edge)?;
            out.push(edge);
        }
        Ok(out)
    }

    /// Whether `id` is listed in the current View's tombstone map.
    ///
    /// `true` means a prior commit on this view recorded a
    /// [`Tombstone`](crate::objects::Tombstone) against the node -
    /// retrieval paths filter it out by default. The underlying Node
    /// block may still exist in the node Prolly tree and remains
    /// addressable by CID; only the "show this to an agent" decision
    /// changes.
    #[must_use]
    pub fn is_tombstoned(&self, id: &NodeId) -> bool {
        self.view.tombstones.contains_key(id)
    }

    /// Fetch the tombstone record for `id`, if any.
    #[must_use]
    pub fn tombstone_for(&self, id: &NodeId) -> Option<&crate::objects::Tombstone> {
        self.view.tombstones.get(id)
    }

    // ---------------- Mutation entrypoint ----------------

    /// Start a transaction. The returned [`Transaction`] holds a cheap
    /// clone of the current repo state; multiple transactions can be
    /// started concurrently but only the first to commit wins (subsequent
    /// commits against stale heads will land on a concurrent op-head in
    /// M8.5's merge model).
    #[must_use]
    pub fn start_transaction(&self) -> Transaction {
        Transaction::new(self.clone())
    }

    // ---------------- Query entrypoint ----------------

    /// Convenience: `Query::new(self)`. One-liner entry point for the
    /// agent-facing retrieval API.
    ///
    /// ```no_run
    /// # use mnem_core::repo::ReadonlyRepo;
    /// # fn demo(repo: &ReadonlyRepo) -> Result<(), Box<dyn std::error::Error>> {
    /// let hits = repo.query().label("Person").where_eq("name", "Alice").execute()?;
    /// # Ok(()) }
    /// ```
    #[must_use]
    pub const fn query(&self) -> crate::index::Query<'_> {
        crate::index::Query::new(self)
    }

    /// Build a full-corpus vector index over every node whose
    /// [`crate::objects::Embedding::model`] equals `model`. Dimensions
    /// are inferred from the first matching embedding; subsequent
    /// embeddings with a different dim are silently skipped.
    ///
    /// Each index binds to a single `(model, dim)` - agents who use
    /// multiple embedding models build one index per model.
    ///
    /// # Errors
    ///
    /// - [`RepoError::Uninitialized`] if the repo has no head commit.
    /// - Store / codec errors from walking the node Prolly tree.
    /// - [`crate::error::ObjectError::EmbeddingSizeMismatch`] on a
    /// corrupted embedding (vector length disagrees with
    /// `dim * bytes_per_dtype`).
    pub fn build_vector_index(
        &self,
        model: &str,
    ) -> Result<crate::index::BruteForceVectorIndex, Error> {
        crate::index::BruteForceVectorIndex::build_from_repo(self, model)
    }

    /// Start an agent-facing retrieval builder that composes the
    /// structured query, dense vector similarity, and learned-sparse
    /// retrieval under a token budget. See [`crate::retrieve`] for the
    /// full model.
    ///
    /// ```no_run
    /// # use mnem_core::repo::ReadonlyRepo;
    /// # fn demo(repo: &ReadonlyRepo, embedding: Vec<f32>) -> Result<(), Box<dyn std::error::Error>> {
    /// let result = repo
    /// .retrieve()
    /// .label("Document")
    /// .vector("openai:text-embedding-3-small", embedding)
    /// .token_budget(2000)
    /// .execute()?;
    /// # Ok(()) }
    /// ```
    #[must_use]
    pub fn retrieve(&self) -> crate::retrieve::Retriever<'_> {
        crate::retrieve::Retriever::new(self)
    }

    // ---------------- Compare-and-swap on refs (SPEC §6.4) ----------------

    /// Atomically update a named ref, subject to an expected-previous
    /// check (SPEC §6.4).
    ///
    /// Semantics:
    ///
    /// 1. Read the current value of `name` in the current view's `refs`.
    /// 2. If the current value does not `==`-compare to `expected_prev`
    /// (structurally equal, not byte-exact - our `RefTarget` derives
    /// `PartialEq` and constructs canonical form), return
    /// [`RepoError::Stale`].
    /// 3. Otherwise, build a new View with the ref updated (insert if
    /// `new` is `Some`, remove if `new` is `None`), a new Operation
    /// wrapping it, advance op-heads, and return a fresh repo.
    ///
    /// Per SPEC §6.4, CAS guarantees **no lost update** - two
    /// concurrent CAS attempts against the same base both succeed at
    /// the op-log layer, and the next read sees a conflicted refs state.
    /// For **exactly-one-winner** semantics, combine with
    /// [`Transaction::commit_opts`]'s `linearize: true` or with an
    /// out-of-process coordinator.
    ///
    /// # Errors
    ///
    /// - [`RepoError::Stale`] on mismatch with `expected_prev`.
    /// - Codec / store errors on write.
    pub fn update_ref(
        &self,
        name: &str,
        expected_prev: Option<&RefTarget>,
        new: Option<RefTarget>,
        author: &str,
    ) -> Result<Self, Error> {
        let current = self.view.refs.get(name);
        if current != expected_prev {
            return Err(RepoError::Stale.into());
        }

        let bs = self.blockstore.clone();
        let ohs = self.op_heads.clone();

        // Build the new View.
        let mut new_view: View = (*self.view).clone();
        match new {
            Some(target) => {
                new_view.refs.insert(name.to_string(), target);
            }
            None => {
                new_view.refs.remove(name);
            }
        }
        let (view_bytes, view_cid) = hash_to_cid(&new_view)?;
        bs.put(view_cid.clone(), view_bytes)?;

        // Build the new Operation wrapping the new view.
        let op = Operation::new(
            view_cid,
            author,
            now_micros(),
            format!("update_ref: {name}"),
        )
        .with_parent(self.op_id.clone());
        let (op_bytes, op_cid) = hash_to_cid(&op)?;
        bs.put(op_cid.clone(), op_bytes)?;

        // Advance op-heads.
        ohs.update(op_cid.clone(), std::slice::from_ref(&self.op_id))?;

        Self::load_at(bs, ohs, op_cid)
    }

    /// Move the working position (`view.heads`) to `new_head` without
    /// touching any named ref. This is how a detached-HEAD fast-forward
    /// works: the merge target becomes the new commit base for the next
    /// `Transaction::commit`, and any branches that existed before are
    /// left exactly where they were.
    ///
    /// Mirrors the `update_ref` pattern exactly (same Op-wrapping,
    /// same `load_at` reload) but writes `heads` instead of `refs`.
    pub fn update_heads(&self, new_head: Cid, author: &str) -> Result<Self, Error> {
        let bs = self.blockstore.clone();
        let ohs = self.op_heads.clone();

        let mut new_view: View = (*self.view).clone();
        new_view.heads = vec![new_head.clone()];

        let (view_bytes, view_cid) = hash_to_cid(&new_view)?;
        bs.put(view_cid.clone(), view_bytes)?;

        let op = Operation::new(
            view_cid,
            author,
            now_micros(),
            format!("update_heads: {new_head}"),
        )
        .with_parent(self.op_id.clone());
        let (op_bytes, op_cid) = hash_to_cid(&op)?;
        bs.put(op_cid.clone(), op_bytes)?;

        ohs.update(op_cid.clone(), std::slice::from_ref(&self.op_id))?;

        Self::load_at(bs, ohs, op_cid)
    }

    /// BUG-38: Switch HEAD to a named branch and record it as the active
    /// branch in `View.extra["active_branch"]`.
    ///
    /// Identical to [`update_heads`] except it also writes the branch
    /// refname into `extra["active_branch"]` so that subsequent
    /// `Transaction::commit_opts` calls know which branch ref to advance
    /// automatically.
    ///
    /// `branch_refname` must be the fully-qualified ref name
    /// (e.g. `"refs/heads/main"`).
    ///
    /// # Errors
    ///
    /// Blockstore or hash errors.
    pub fn switch_branch(
        &self,
        new_head: Cid,
        branch_refname: impl Into<String>,
        author: &str,
    ) -> Result<Self, Error> {
        let bs = self.blockstore.clone();
        let ohs = self.op_heads.clone();

        let branch_ref: String = branch_refname.into();
        let mut new_view: View = (*self.view).clone();
        new_view.heads = vec![new_head.clone()];
        new_view.extra.insert(
            "active_branch".to_string(),
            Ipld::String(branch_ref.clone()),
        );

        let (view_bytes, view_cid) = hash_to_cid(&new_view)?;
        bs.put(view_cid.clone(), view_bytes)?;

        let op = Operation::new(
            view_cid,
            author,
            now_micros(),
            format!("switch: {branch_ref} -> {new_head}"),
        )
        .with_parent(self.op_id.clone());
        let (op_bytes, op_cid) = hash_to_cid(&op)?;
        bs.put(op_cid.clone(), op_bytes)?;

        ohs.update(op_cid.clone(), std::slice::from_ref(&self.op_id))?;

        Self::load_at(bs, ohs, op_cid)
    }

    // Remote-v0 insertion point: `update_remote_ref(remote_name,
    // ref_name, target) -> Result<Self, Error>` will mutate
    // `View.remote_refs[remote][ref]` atomically (same
    // Operation-wrapping pattern as `update_ref` above). Called by
    // the `mnem fetch` path after a successful
    // `GET /remote/v1/refs` + `POST /remote/v1/fetch-blocks` round.
    // Must NOT mutate `View.refs` (local heads stay untouched until
    // `mnem pull` merges). See
    // `docs/ROADMAP.md#remote-v0-work-items-tracked-inline-in-src`
    // item 3 and ().
}

/// Helper: fetch and decode a typed object from a blockstore.
pub(crate) fn decode_from_store<T, B>(store: &B, cid: &Cid) -> Result<T, Error>
where
    B: Blockstore + ?Sized,
    T: serde::de::DeserializeOwned,
{
    let bytes = store
        .get(cid)?
        .ok_or_else(|| StoreError::NotFound { cid: cid.clone() })?;
    Ok(from_canonical_bytes(&bytes)?)
}

impl std::fmt::Debug for ReadonlyRepo {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ReadonlyRepo")
            .field("op_id", &self.op_id)
            .field("heads", &self.view.heads)
            .field("has_commit", &self.commit.is_some())
            .finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::id::{CODEC_RAW, Multihash};
    use crate::store::{MemoryBlockstore, MemoryOpHeadsStore};

    fn stores() -> (Arc<dyn Blockstore>, Arc<dyn OpHeadsStore>) {
        (
            Arc::new(MemoryBlockstore::new()),
            Arc::new(MemoryOpHeadsStore::new()),
        )
    }

    fn raw_cid(seed: u32) -> Cid {
        Cid::new(CODEC_RAW, Multihash::sha2_256(&seed.to_be_bytes()))
    }

    #[test]
    fn init_creates_a_valid_root() {
        let (bs, ohs) = stores();
        let repo = ReadonlyRepo::init(bs.clone(), ohs.clone()).unwrap();
        assert!(repo.head_commit().is_none());
        assert!(repo.view().heads.is_empty());
        assert_eq!(ohs.current().unwrap().len(), 1);
        assert_eq!(ohs.current().unwrap()[0], *repo.op_id());
    }

    #[test]
    fn open_on_uninitialized_errors() {
        let (bs, ohs) = stores();
        let err = ReadonlyRepo::open(bs, ohs).unwrap_err();
        match err {
            Error::Repo(RepoError::Uninitialized) => {}
            e => panic!("unexpected variant: {e:?}"),
        }
    }

    #[test]
    fn open_after_init_returns_the_same_op() {
        let (bs, ohs) = stores();
        let first = ReadonlyRepo::init(bs.clone(), ohs.clone()).unwrap();
        let second = ReadonlyRepo::open(bs, ohs).unwrap();
        assert_eq!(first.op_id(), second.op_id());
    }

    #[test]
    fn update_ref_creates_new_ref() {
        let (bs, ohs) = stores();
        let repo = ReadonlyRepo::init(bs, ohs).unwrap();
        let target = RefTarget::normal(raw_cid(1));
        let r1 = repo
            .update_ref("refs/heads/main", None, Some(target.clone()), "alice")
            .unwrap();
        assert_eq!(r1.view().refs.get("refs/heads/main"), Some(&target));
    }

    #[test]
    fn update_ref_returns_stale_on_expected_mismatch() {
        let (bs, ohs) = stores();
        let repo = ReadonlyRepo::init(bs, ohs).unwrap();
        // Ref doesn't exist yet, but we claim it was at some CID.
        let stale = RefTarget::normal(raw_cid(99));
        let err = repo
            .update_ref("refs/heads/main", Some(&stale), None, "alice")
            .unwrap_err();
        assert!(matches!(err, Error::Repo(RepoError::Stale)));
    }

    #[test]
    fn update_ref_cas_sequence_then_delete() {
        let (bs, ohs) = stores();
        let repo = ReadonlyRepo::init(bs, ohs).unwrap();
        let v1 = RefTarget::normal(raw_cid(1));
        let v2 = RefTarget::normal(raw_cid(2));

        let r1 = repo
            .update_ref("refs/heads/x", None, Some(v1.clone()), "a")
            .unwrap();
        let r2 = r1
            .update_ref("refs/heads/x", Some(&v1), Some(v2.clone()), "a")
            .unwrap();
        assert_eq!(r2.view().refs.get("refs/heads/x"), Some(&v2));

        let r3 = r2.update_ref("refs/heads/x", Some(&v2), None, "a").unwrap();
        assert!(!r3.view().refs.contains_key("refs/heads/x"));
    }
}
