//! Tier-1 module — decorators, dialects, and the t1 decoder/encoder.
//!
//! Status: feature-complete batch decoder/encoder.
//!
//! - [`decode_t1`] / [`decode_t1_with_registry`] handle tier-0 and
//!   tier-1 input. Sidecar attachment covers Leading / Inner /
//!   Trailing / Floating positions including inside flow forms
//!   (`[...]` / `{...}`). Decoration-only form uses the family's
//!   `empty_default`. Nested decoration on param values is captured
//!   in [`DecoratorCall::params_dec`]. Hoist pass moves
//!   `content_slot` params into the value tree. Family resolution
//!   applies alias / allow / deny rules with ambiguity detection.
//!   Param-signature validation runs `wildcard` / `wildcard_with_typed`
//!   / `strict` / `positional` modes including variadic last-slot
//!   collection and recursive named-struct validation.
//! - [`encode_t1`] / [`encode_t1_with_registry`] round-trip tier-1
//!   documents back to source with three canonical-form heuristics:
//!   (1) Inner-position decoration-only suppression — body equals
//!   `empty_default` → emit `key: |dec` with no value token.
//!   (2) Leading-position empty_default suppression — Leading call
//!   with body equals `empty_default` → shift position to Inner
//!   (lossy) and suppress body. (3) Flow re-fold — Leading/Inner
//!   call with all-scalar List body and `content_slot` → fold list
//!   into the slot param and shift Leading→Inner, suppress block emit.
//! - Dialect versioning: five strategies (`exact` / `caret` / `tilde`
//!   / `gte` / `any`) with caret as default. Pre-release ordering
//!   per semver. Range-specifier syntax in file (`^x.y.z`) rejected.
//! - Streaming decoder: not implemented. Per TIER1.md §"Streaming
//!   / incremental decode" line 1466, streaming is optional per
//!   port. dms-rs ships batch-only.
//!
//! Cite TIER1.md for the full contract; this module is the wiring.

use indexmap::IndexMap;

use crate::{BreadcrumbSegment, Comment, DecodeError, Document, EncodeError};

// ── Import types ─────────────────────────────────────────────────────────────

/// One dialect import parsed from `_dms_imports` in tier-1 front matter.
///
/// See TIER1.md §"Import shape" (line 183) for the full grammar.
/// All fields map directly to their spec counterparts; the names here
/// use snake_case where the spec uses camelCase prose.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportSpec {
    /// The dialect name. Resolved against the decoder's installed
    /// dialect registry at step 6 (not at front-matter parse time).
    /// TIER1.md §"Import shape" — required identity field.
    pub dialect: String,
    /// Raw semver string (`MAJOR.MINOR.PATCH` with optional
    /// `-pre.release` and `+build.metadata`). Full semver matching
    /// against the installed registry happens at step 6; here we only
    /// check that the string has the right shape. TIER1.md §"Import
    /// shape" — required identity field.
    pub version: String,
    /// Namespace qualifier. When set, `|<ns>.<fn>(...)` calls resolve
    /// directly through this import's dialect. TIER1.md §"Import shape"
    /// — optional identity field.
    pub ns: Option<String>,
    /// Sigil-to-family bindings for this file. Each key must be a
    /// non-empty sequence of chars from the tier-0 reserved decorator
    /// sigil set (TIER1.md §"Sigil validation", line 350). Each value
    /// is a list of family names (list form required even for a single
    /// family). TIER1.md §"Import shape" — optional syntax field.
    pub bind: IndexMap<String, Vec<String>>,
    /// Allow-list per family. TIER1.md §"Import shape" — optional
    /// curation field; mutually exclusive with `deny` for the same
    /// family.
    pub allow: IndexMap<String, Vec<String>>,
    /// Deny-list per family. TIER1.md §"Import shape" — optional
    /// curation field; mutually exclusive with `allow` for the same
    /// family (TIER1.md line 245-247).
    pub deny: IndexMap<String, Vec<String>>,
    /// Per-family alias maps (alias → canonical name). TIER1.md
    /// §"Import shape" — optional curation field.
    pub alias: IndexMap<String, IndexMap<String, String>>,
}

/// Collected and validated imports from a tier-1 document's
/// `_dms_imports` front-matter list.
///
/// Empty (`imports: []`) is valid — a tier-1 document that uses only
/// `_`-prefixed (core) decorators needs no dialect imports.
/// TIER1.md §"Resolution rules" (line 255): step 2 stores the parsed
/// imports here; per-call resolution is step 3 (body parse, not yet
/// implemented).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ResolvedImports {
    /// Imports in source order, validated for sigil correctness and
    /// cross-import collision (TIER1.md §"Conflict detection —
    /// Front-matter-time", line 313).
    pub imports: Vec<ImportSpec>,
}

// ── Document types ────────────────────────────────────────────────────────────

/// Tier-1 document — the tier-0 [`Document`] (body, front matter,
/// comments, original-form sidecar) plus the decorator sidecar and the
/// validated import table.
///
/// TIER1.md §"Document types" describes this as a strict
/// superset: `Document_t1 = Document_t0 + { decorators }`. Rust
/// has no subtyping, so we compose — a [`DocumentT0`] is
/// convertible to a [`DocumentT1`] with [`From`] (zero-decorator
/// promotion).
#[derive(Debug, Clone, PartialEq)]
pub struct DocumentT1 {
    /// Tier-0 portion: `meta` + `body` + `comments` +
    /// `original_forms`. Untouched by the t1 layer except where
    /// hoisting moves decorator content into `body`.
    pub t0: Document,
    /// Decorator sidecar — list of entries, one per decorated
    /// node, in source order. Empty list when the source is
    /// tier-0 (no `_dms_tier: 1`). See TIER1.md §"AST shape".
    pub decorators: Vec<DecoratorEntry>,
    /// Validated dialect imports from `_dms_imports`. Empty when the
    /// source is tier-0 or when the tier-1 document imports no
    /// dialects. See TIER1.md §"Front matter additions" (line 170).
    pub imports: ResolvedImports,
}

/// Spec alias for the tier-0 document type ([`Document`] /
/// `Document_t0` in TIER1.md). Tier-0-only code uses [`Document`]
/// directly; this alias is the explicit spelling for tier-1-aware
/// code that wants to be unambiguous about which tier its inputs
/// and outputs are.
pub type DocumentT0 = Document;

impl From<Document> for DocumentT1 {
    /// Promote a tier-0 document into a tier-1-shaped document
    /// with an empty `decorators` list and default (empty) imports.
    /// This is the `Document_t0 → Document_t1` conversion called
    /// out in TIER1.md §"Document types" for languages without
    /// subtyping.
    fn from(t0: Document) -> Self {
        DocumentT1 { t0, decorators: Vec::new(), imports: ResolvedImports::default() }
    }
}

/// Path-rewriting mutation helpers for [`DocumentT1`].
///
/// Bypasses the path-keyed sidecar staleness trap: when a caller
/// inserts/removes nodes from the body tree, the existing decorators
/// keyed by old paths become misaligned. `mutate_t1` operations
/// rewrite both the body value at the target path and the path
/// segments in the decorators sidecar that are affected by the
/// operation.
///
/// Use for any structural change (insert / remove / move).
/// Direct mutation of `doc.t0.body` is fine for in-place value
/// changes where the path doesn't move.
impl DocumentT1 {
    /// Insert `value` at `path`, which must point to either:
    /// - A list index in the range `0..=len` (inserts before that index).
    /// - A key in a table (inserts the key/value pair).
    ///
    /// After a list insert, every decorator entry whose path contains an
    /// `Index >= insertion_index` at the matching parent prefix gets that
    /// segment incremented by 1.
    pub fn insert_at(
        &mut self,
        path: &[BreadcrumbSegment],
        value: crate::Value,
    ) -> Result<(), String> {
        if path.is_empty() {
            return Err("insert_at: path must not be empty".into());
        }
        let (parent_path, last_seg) = path.split_at(path.len() - 1);
        let last_seg = &last_seg[0];

        // Navigate to parent.
        let parent = navigate_body_mut(&mut self.t0.body, parent_path)
            .ok_or_else(|| format!("insert_at: invalid parent path {:?}", parent_path))?;

        match (last_seg, parent) {
            (BreadcrumbSegment::Index(idx), crate::Value::List(list)) => {
                let len = list.len();
                if *idx > len {
                    return Err(format!(
                        "insert_at: index {} out of bounds for list of length {}",
                        idx, len
                    ));
                }
                list.insert(*idx, value);
                // Shift decorator paths: any entry whose path at position
                // `parent_path.len()` is an Index >= idx gets incremented.
                let prefix_len = parent_path.len();
                for entry in &mut self.decorators {
                    if entry.path.len() > prefix_len
                        && entry.path[..prefix_len] == *parent_path
                    {
                        if let BreadcrumbSegment::Index(ref mut i) = entry.path[prefix_len] {
                            if *i >= *idx {
                                *i += 1;
                            }
                        }
                    }
                }
                Ok(())
            }
            (BreadcrumbSegment::Key(k), crate::Value::Table(table)) => {
                table.insert(k.clone(), value);
                Ok(())
            }
            (seg, parent_val) => Err(format!(
                "insert_at: segment {:?} is incompatible with parent value type {}",
                seg,
                match parent_val {
                    crate::Value::List(_) => "list",
                    crate::Value::Table(_) | crate::Value::UnorderedTable(_) => "table",
                    _ => "scalar",
                }
            )),
        }
    }

    /// Remove the value at `path`.
    ///
    /// For list paths: decorators attached at or under `path` are dropped;
    /// decorator paths with siblings whose `Index > removed_index` get
    /// decremented.
    ///
    /// For table paths: decorators at or under `path` are dropped.
    pub fn remove_at(
        &mut self,
        path: &[BreadcrumbSegment],
    ) -> Result<crate::Value, String> {
        if path.is_empty() {
            return Err("remove_at: path must not be empty".into());
        }
        let (parent_path, last_seg) = path.split_at(path.len() - 1);
        let last_seg = &last_seg[0];

        let parent = navigate_body_mut(&mut self.t0.body, parent_path)
            .ok_or_else(|| format!("remove_at: invalid parent path {:?}", parent_path))?;

        match (last_seg, parent) {
            (BreadcrumbSegment::Index(idx), crate::Value::List(list)) => {
                if *idx >= list.len() {
                    return Err(format!(
                        "remove_at: index {} out of bounds for list of length {}",
                        idx,
                        list.len()
                    ));
                }
                let removed = list.remove(*idx);
                let prefix_len = parent_path.len();
                // Drop decorators at or under path; shift higher indices.
                self.decorators.retain(|entry| {
                    if entry.path.len() <= prefix_len {
                        return true; // ancestor or unrelated — keep
                    }
                    if entry.path[..prefix_len] != *parent_path {
                        return true; // different branch — keep
                    }
                    // Same parent prefix — check the segment at prefix_len.
                    match &entry.path[prefix_len] {
                        BreadcrumbSegment::Index(i) => *i != *idx,
                        _ => true,
                    }
                });
                // Shift indices > removed.
                let prefix_len = parent_path.len();
                for entry in &mut self.decorators {
                    if entry.path.len() > prefix_len
                        && entry.path[..prefix_len] == *parent_path
                    {
                        if let BreadcrumbSegment::Index(ref mut i) = entry.path[prefix_len] {
                            if *i > *idx {
                                *i -= 1;
                            }
                        }
                    }
                }
                Ok(removed)
            }
            (BreadcrumbSegment::Key(k), crate::Value::Table(table)) => {
                let removed = table
                    .swap_remove(k.as_str())
                    .ok_or_else(|| format!("remove_at: key '{}' not found", k))?;
                // Drop decorators at or under this key.
                let prefix_len = parent_path.len();
                let k_clone = k.clone();
                self.decorators.retain(|entry| {
                    if entry.path.len() <= prefix_len {
                        return true;
                    }
                    if entry.path[..prefix_len] != *parent_path {
                        return true;
                    }
                    match &entry.path[prefix_len] {
                        BreadcrumbSegment::Key(ek) => ek != &k_clone,
                        _ => true,
                    }
                });
                Ok(removed)
            }
            (seg, parent_val) => Err(format!(
                "remove_at: segment {:?} is incompatible with parent value type {}",
                seg,
                match parent_val {
                    crate::Value::List(_) => "list",
                    crate::Value::Table(_) | crate::Value::UnorderedTable(_) => "table",
                    _ => "scalar",
                }
            )),
        }
    }

    /// Replace the value at `path` with `value`.
    ///
    /// Decorators attached at or under `path` are preserved by default.
    /// Pass `drop_descendant_decorators = true` to also remove decorators
    /// at descendant paths (useful when replacing a subtree with a scalar).
    pub fn replace_at(
        &mut self,
        path: &[BreadcrumbSegment],
        value: crate::Value,
        drop_descendant_decorators: bool,
    ) -> Result<crate::Value, String> {
        if path.is_empty() {
            // Replace root.
            let old = std::mem::replace(&mut self.t0.body, value);
            if drop_descendant_decorators {
                self.decorators.clear();
            }
            return Ok(old);
        }

        let target = navigate_body_mut(&mut self.t0.body, path)
            .ok_or_else(|| format!("replace_at: invalid path {:?}", path))?;
        let old = std::mem::replace(target, value);

        if drop_descendant_decorators {
            let prefix_len = path.len();
            self.decorators.retain(|entry| {
                // Keep if the path is strictly shorter than or equal to path
                // (i.e. not a descendant).
                !(entry.path.len() > prefix_len && entry.path[..prefix_len] == *path)
            });
        }
        Ok(old)
    }
}

/// Navigate to a mutable reference in the body tree. Identical to the
/// module-level `body_at_path_mut` but callable before that function is
/// defined; kept as a forwarding alias after we move the canonical
/// definition below.
///
/// (This exists so the `impl DocumentT1` block above does not depend on
/// declaration order.)
fn navigate_body_mut<'a>(
    body: &'a mut crate::Value,
    path: &[BreadcrumbSegment],
) -> Option<&'a mut crate::Value> {
    let mut cur = body;
    for seg in path {
        match seg {
            BreadcrumbSegment::Key(k) => match cur {
                crate::Value::Table(t) => cur = t.get_mut(k.as_str())?,
                _ => return None,
            },
            BreadcrumbSegment::Index(i) => match cur {
                crate::Value::List(l) => cur = l.get_mut(*i)?,
                _ => return None,
            },
        }
    }
    Some(cur)
}

/// One sidecar entry — every decoration kind (sigil-keyed
/// decorator calls, comments) attached to a single value-tree
/// node shares a single entry. Path is the unique key.
///
/// The spec models the kinds map as `<kind>: List<Record>` with
/// per-kind record shapes (TIER1.md §"AST shape"). We split the
/// sigil-keyed kinds out from the comments kind here because the
/// record types differ — Rust enums would re-introduce a
/// stringly-typed key, which buys nothing over named fields.
#[derive(Debug, Clone, PartialEq)]
pub struct DecoratorEntry {
    /// Path to the decorated node. Empty `[]` is the document
    /// root. Reuses tier-0's typed-segment path
    /// ([`BreadcrumbSegment::Key`] for map keys,
    /// [`BreadcrumbSegment::Index`] for list indices). Front-
    /// matter decorations follow the `__fm__` prefix convention
    /// from tier-0 comments.
    pub path: Vec<BreadcrumbSegment>,
    /// Sigil-keyed decorator calls. Outer key is the literal
    /// sigil (`"|"`, `"@"`, `"!"`, …) the file bound for that
    /// family namespace; inner list is calls of that sigil in
    /// source order. `IndexMap` preserves source-encounter order
    /// of the sigil keys themselves.
    pub decorators: IndexMap<String, Vec<DecoratorCall>>,
    /// Comments attached to this path. Populated only when the
    /// t1 decoder is configured to hoist comments into the
    /// sidecar; the default is to leave them in
    /// [`Document::comments`] for tier-0 compatibility. See
    /// TIER1.md §"Stacking and interleaving with comments".
    pub comments: Vec<DecorationComment>,
}

/// A decorator call as written in source: identity (`family`,
/// `fn_name`), ordered param groups, nested-decoration sidecar,
/// and the position the call occupied at source. See TIER1.md
/// §"AST shape" for the per-field contract.
#[derive(Debug, Clone, PartialEq)]
pub struct DecoratorCall {
    /// The literal sigil string as written in source (e.g., `"|"`,
    /// `"||"`, `"|@"`). Used by `group_decorations` to key the
    /// `decorators` map in [`DecoratorEntry`]. Step 6 maps sigil →
    /// dialect family via the file's `bind` table.
    pub sigil: String,
    /// Canonical family name from the dialect (e.g., `"tag"`).
    /// Resolved at parse time via the file's bound sigils + the
    /// dialect's family table.
    pub family: String,
    /// Literal name written after the sigil (e.g., `"html"` in
    /// `|html(...)`). Always the canonical name (post-alias-
    /// resolution), always present. Named `fn_name` because
    /// `fn` is a Rust keyword; spec calls this slot `fn`.
    pub fn_name: String,
    /// Namespace qualifier extracted from the source form.
    /// Set when the source used the fully-qualified `|<ns>.<fn>`
    /// form (TIER1.md §"Decorator call syntax"). Step 6 uses this
    /// for cross-dialect disambiguation. `None` for bare `|fn`
    /// form where only a single name follows the sigil.
    pub ns: Option<String>,
    /// Ordered list of param groups. Each group is one of:
    ///
    /// - [`ParamGroup::Named`] — flow-table-shaped
    ///   (`{ key: value, … }`). Decoded form for `|tag(a: 1)`
    ///   is one group `Named({a: 1})`.
    /// - [`ParamGroup::Positional`] — flow-array-shaped
    ///   (`[v0, v1, …]`). Decoded form for `|score(95, 5)` is
    ///   one group `Positional([95, 5])`.
    ///
    /// Mode is detected at the open `(` per TIER1.md §"Positional
    /// params"; mixing within a group is a parse error.
    /// Multi-group calls (`|tag(a:1)(b:2)`) produce one entry per
    /// group.
    pub params: Vec<ParamGroup>,
    /// Parallel sidecar for nested decoration on param values.
    /// Same shape as the top-level [`DocumentT1::decorators`]
    /// list, but rooted at this call's `params`. Empty when no
    /// nested decoration is present. See TIER1.md §"AST shape"
    /// for the path-segment-into-`params` rule.
    pub params_dec: Vec<DecoratorEntry>,
    /// Which of the four decoration positions the call occupied
    /// at source ([`DecorationPosition`]). The `path` already
    /// identifies *which* node the decoration attaches to;
    /// `position` records *how* it was written so encoders can
    /// round-trip back to the same form.
    pub position: DecorationPosition,
}

/// One param group inside a decorator call's `params` list.
///
/// Each group is detected as named or positional at the open `(`
/// (TIER1.md §"Lexer rule"). Empty parens (`|tag()`) and bare
/// (`|tag`) are equivalent and decode to one [`ParamGroup::Named`]
/// holding an empty map.
#[derive(Debug, Clone, PartialEq)]
pub enum ParamGroup {
    /// Flow-table-shaped group (`{ k: v, … }` semantics). Param
    /// values are tier-0 inline-value shapes, modulo nested
    /// decoration captured in [`DecoratorCall::params_dec`].
    Named(crate::DmsMap<crate::Value>),
    /// Flow-array-shaped group (`[v0, v1, …]` semantics).
    /// Variadic-positional element-level typing applies at
    /// validation time, not at this AST level.
    Positional(Vec<crate::Value>),
}

/// Decoration attachment position — mirrors tier-0
/// [`crate::CommentPosition`] one-for-one. See TIER1.md
/// §"Decoration attachment".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecorationPosition {
    /// Own line(s) immediately before a sibling node (no blank
    /// line gap). Attaches at the following node's path.
    Leading,
    /// Between `key:` (or `+`) and the value, same line.
    /// Attaches at the node's value path.
    Inner,
    /// After the value, same line, before newline. Attaches at
    /// the node's value path.
    Trailing,
    /// Own line(s), blank-line-separated, or after the last
    /// child of a block. Attaches at the enclosing block's path.
    Floating,
}

/// A comment record carried inside [`DecoratorEntry::comments`].
/// Only populated by the t1 decoder when comments are hoisted
/// into the decorator sidecar; the default decode keeps comments
/// in [`Document::comments`] (with the existing tier-0 shape) so
/// tier-0 consumers see no change.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecorationComment {
    /// Underlying comment (raw text + line/block kind), reused
    /// from tier-0.
    pub comment: Comment,
    /// Which of the four decoration positions the comment
    /// occupied at source. Same enum as [`DecoratorCall`] — the
    /// spec mirrors decorator and comment positions deliberately.
    pub position: DecorationPosition,
}

/// Capability advertisement — this build of the t1 module is a
/// skeleton. `false` until the body lexer/parser/emitter land. Lets
/// callers probe whether the t1 entry points are usable for
/// tier-1 input (tier-0 input round-trips through them either
/// way, see [`decode_t1`]).
pub const SUPPORTS_TIER1: bool = false;

// ── Dialect-registry types ────────────────────────────────────────────────────

/// One declared type in a `typed` signature or struct field.
#[derive(Debug, Clone, PartialEq)]
pub enum TypeSpec {
    String,
    Integer,
    Float,
    Boolean,
    Datetime,
    /// `list_of <T>` — homogeneous list with elements of `T`.
    ListOf(Box<TypeSpec>),
    /// `map_of <T>` — homogeneous map with values of `T`.
    MapOf(Box<TypeSpec>),
    /// Any tier-0 value-tree shape.
    Any,
    /// Reference to a named struct in the dialect's `structs:` block.
    Struct(std::string::String),
}

/// A typed param entry — used in `typed` and in struct fields.
#[derive(Debug, Clone, PartialEq)]
pub struct TypedParam {
    pub ty: TypeSpec,
    pub required: bool,
    /// Default value applied when the key is absent. `None` = no
    /// default (and `required = false` means absence is allowed
    /// without filling).
    pub default: Option<crate::Value>,
}

/// One positional slot in a `positional` block.
#[derive(Debug, Clone, PartialEq)]
pub struct PositionalSlot {
    pub name: std::string::String,
    pub ty: TypeSpec,
    pub required: bool,
    pub default: Option<crate::Value>,
    /// Last-slot-only. Surplus elements collect into this slot's list.
    /// TIER1.md §"Variadic positional slot" (line 1047).
    pub variadic: bool,
}

/// Param-signature mode — TIER1.md §"Param signatures" + §"Positional
/// params — Per-family signature".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParamMode {
    /// Any keys accepted; no checks. Default if no `params` block.
    Wildcard,
    /// Any keys accepted; declared `typed` keys are type-checked.
    WildcardWithTyped,
    /// Only declared `typed` keys accepted; unknown keys are errors.
    Strict,
    /// Positional groups accepted; named groups also accepted with
    /// `typed` rules. TIER1.md line 1020.
    Positional,
}

/// Param signature for a family.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ParamSignature {
    pub mode: Option<ParamMode>, // None = Wildcard default
    pub typed: IndexMap<std::string::String, TypedParam>,
    pub required: Vec<std::string::String>,
    pub positional: Vec<PositionalSlot>,
}

/// One family declaration in a dialect spec.
///
/// TIER1.md §"Families" (line 789). All fields except `name` and
/// `default_sigils` are spec-optional.
#[derive(Debug, Clone, PartialEq)]
pub struct FamilySpec {
    pub name: std::string::String,
    pub default_sigils: Vec<std::string::String>,
    /// Value-tree default for decoration-only positions. Typically
    /// `Value::Table(empty)` for record-shaped families,
    /// `Value::List(empty)` for collection-shaped, `""` for string-
    /// shaped, etc.
    pub empty_default: crate::Value,
    /// Param name that gets hoisted into the value tree on decode.
    /// `None` = no hoisting (family has no value-bearing children).
    pub content_slot: Option<std::string::String>,
    /// Param signature; defaults to wildcard (`ParamSignature::default()`)
    /// when not explicitly declared.
    pub params: ParamSignature,
}

/// One named struct in the dialect's `structs:` block.
#[derive(Debug, Clone, PartialEq)]
pub struct StructSpec {
    pub name: std::string::String,
    pub fields: IndexMap<std::string::String, TypedParam>,
}

/// Version-match strategy for dialect resolution.
///
/// TIER1.md §"Dialect versioning" (line 1257). Stored on each
/// installed [`DialectSpec`] — the registry reads it when deciding
/// whether an installed version satisfies a file's `version` request.
///
/// Default (if undeclared): [`VersionStrategy::Caret`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VersionStrategy {
    /// Installed == requested (core + pre tags must match exactly).
    Exact,
    /// Same major, installed >= requested. For `0.x.y` (major == 0),
    /// behaves like Tilde (same major.minor, patch >=) because pre-1.0
    /// minor bumps are breaking per semver convention.
    Caret,
    /// Same major.minor, installed patch >= requested patch.
    Tilde,
    /// Installed >= requested (any major).
    Gte,
    /// Any installed version matches, regardless of value.
    Any,
}

impl Default for VersionStrategy {
    fn default() -> Self {
        VersionStrategy::Caret
    }
}

/// A complete dialect specification.
///
/// TIER1.md §"Dialect specification contract" (line 777).
#[derive(Debug, Clone, PartialEq)]
pub struct DialectSpec {
    pub name: std::string::String,
    pub version: std::string::String,
    /// Version-match strategy used when resolving this dialect against a
    /// file's `version` field. Default: [`VersionStrategy::Caret`].
    pub version_strategy: VersionStrategy,
    pub families: Vec<FamilySpec>,
    pub structs: Vec<StructSpec>,
}

impl DialectSpec {
    pub fn family(&self, name: &str) -> Option<&FamilySpec> {
        self.families.iter().find(|f| f.name == name)
    }
    pub fn struct_def(&self, name: &str) -> Option<&StructSpec> {
        self.structs.iter().find(|s| s.name == name)
    }
}

/// Registry of installed dialects. Ports populate this at startup.
pub trait DialectRegistry {
    /// Resolve a dialect by name + version requirement string. The
    /// version-match rule is per-dialect (TIER1.md §"Dialect
    /// versioning"); for now the default impl uses exact match.
    fn resolve(&self, name: &str, version_req: &str) -> Option<&DialectSpec>;
}

/// Empty registry — `resolve` always returns `None`. Use as the
/// default when callers don't have any dialects loaded; tier-1
/// decode without registry skips hoisting and validation.
#[derive(Debug, Clone, Default)]
pub struct EmptyRegistry;

impl DialectRegistry for EmptyRegistry {
    fn resolve(&self, _name: &str, _version_req: &str) -> Option<&DialectSpec> {
        None
    }
}

/// In-memory registry — wraps a `Vec<DialectSpec>`. Suitable for
/// tests and for ports that load all their dialects at startup. Looks
/// up by exact `(name, version)` match (the most common case for
/// fixed-version registries like tests).
#[derive(Debug, Clone, Default)]
pub struct InMemoryRegistry {
    pub dialects: Vec<DialectSpec>,
}

impl InMemoryRegistry {
    pub fn new() -> Self { Self::default() }
    pub fn with(mut self, spec: DialectSpec) -> Self {
        self.dialects.push(spec);
        self
    }
}

impl DialectRegistry for InMemoryRegistry {
    fn resolve(&self, name: &str, version_req: &str) -> Option<&DialectSpec> {
        let req = parse_semver(version_req)?;
        // Collect all dialects with matching name.
        let mut candidates: Vec<&DialectSpec> = self
            .dialects
            .iter()
            .filter(|d| d.name == name)
            .collect();
        if candidates.is_empty() {
            return None;
        }
        // Filter to those satisfying their own strategy against the request.
        candidates.retain(|d| {
            let inst = match parse_semver(&d.version) {
                Some(v) => v,
                None => return false,
            };
            version_satisfies(&inst, &req, d.version_strategy)
        });
        if candidates.is_empty() {
            return None;
        }
        // Return the highest matching version.
        candidates.sort_by(|a, b| {
            let av = parse_semver(&a.version).unwrap();
            let bv = parse_semver(&b.version).unwrap();
            cmp_semver(&av, &bv)
        });
        candidates.last().copied()
    }
}

// ── Built-in dialect helpers ──────────────────────────────────────────────────

/// Built-in `dms+html` dialect spec — version 1.0.0, caret strategy.
///
/// Families:
/// - `tag`: bound to `|` by default; has a `children` content slot;
///   `empty_default` is an empty table. Wildcard param mode (any name
///   accepted).
/// - `entity`: bound to `&` by default; no content slot;
///   `empty_default` is an empty string. Wildcard param mode.
///
/// Both families are wildcard mode (any name accepted).
///
/// This matches the html dialect described in `dms/dialects/dms+html.md`.
/// Callers that need a working html dialect can use this instead of
/// constructing a [`DialectSpec`] by hand.
pub fn dms_html_dialect() -> DialectSpec {
    DialectSpec {
        name: "html".into(),
        version: "1.0.0".into(),
        version_strategy: VersionStrategy::Caret,
        families: vec![
            FamilySpec {
                name: "tag".into(),
                default_sigils: vec!["|".into()],
                empty_default: crate::Value::Table(Default::default()),
                content_slot: Some("children".into()),
                params: ParamSignature::default(),
            },
            FamilySpec {
                name: "entity".into(),
                default_sigils: vec!["&".into()],
                empty_default: crate::Value::String(String::new()),
                content_slot: None,
                params: ParamSignature::default(),
            },
        ],
        structs: vec![],
    }
}

// ── Import extraction and validation ─────────────────────────────────────────


// ── Semver parsing and matching ───────────────────────────────────────────────

/// One pre-release identifier (between `-` and end / next `.`).
///
/// Semver spec item 9: numeric identifiers have no leading zeros;
/// alphanumeric identifiers may contain `[A-Za-z0-9-]`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SemverPreId {
    Numeric(u64),
    Alphanumeric(std::string::String),
}

/// Parsed semver `MAJOR.MINOR.PATCH[-PRE][+BUILD]`.
///
/// Build metadata is dropped at parse time and is not stored; it is
/// always ignored for matching per semver spec item 10.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SemverParts {
    pub major: u64,
    pub minor: u64,
    pub patch: u64,
    /// Pre-release identifiers, in order. Empty = release version.
    pub pre: Vec<SemverPreId>,
}

/// Parse a semver string `MAJOR.MINOR.PATCH[-PRE][+BUILD]`.
///
/// Returns `None` on any malformed input:
/// - Not three dot-separated integers in the core.
/// - Leading zeros on any core component (e.g. `01.0.0`).
/// - Pre-release identifiers containing invalid chars.
/// - Leading zeros on numeric pre-release identifiers (except `0`).
pub(crate) fn parse_semver(s: &str) -> Option<SemverParts> {
    // 1. Drop build metadata (everything after first `+`).
    let s = match s.find('+') {
        Some(i) => &s[..i],
        None => s,
    };

    // 2. Split on first `-` → core + pre.
    let (core_str, pre_str) = match s.find('-') {
        Some(i) => (&s[..i], Some(&s[i + 1..])),
        None => (s, None),
    };

    // 3. Parse core: exactly three dot-separated non-negative integers,
    //    no leading zeros (except the value 0 itself).
    let core_parts: Vec<&str> = core_str.splitn(5, '.').collect();
    if core_parts.len() != 3 {
        return None;
    }
    let parse_core_component = |p: &str| -> Option<u64> {
        if p.is_empty() {
            return None;
        }
        if p.len() > 1 && p.starts_with('0') {
            return None; // leading zero
        }
        if !p.bytes().all(|b| b.is_ascii_digit()) {
            return None;
        }
        p.parse::<u64>().ok()
    };
    let major = parse_core_component(core_parts[0])?;
    let minor = parse_core_component(core_parts[1])?;
    let patch = parse_core_component(core_parts[2])?;

    // 4. Parse pre-release identifiers.
    let pre = if let Some(pre_raw) = pre_str {
        if pre_raw.is_empty() {
            return None; // trailing `-` with nothing after
        }
        let mut ids = Vec::new();
        for id in pre_raw.split('.') {
            if id.is_empty() {
                return None; // empty identifier (e.g. `1.0.0-rc..1`)
            }
            // All-digit → numeric.
            if id.bytes().all(|b| b.is_ascii_digit()) {
                // No leading zeros except `0`.
                if id.len() > 1 && id.starts_with('0') {
                    return None;
                }
                ids.push(SemverPreId::Numeric(id.parse::<u64>().ok()?));
            } else {
                // Alphanumeric: only `[A-Za-z0-9-]` allowed.
                if !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
                    return None;
                }
                ids.push(SemverPreId::Alphanumeric(id.to_string()));
            }
        }
        ids
    } else {
        Vec::new()
    };

    Some(SemverParts { major, minor, patch, pre })
}

/// Lexicographic semver comparison per https://semver.org/#spec-item-11.
///
/// - Core version (`major.minor.patch`) compared numerically first.
/// - Pre-release < release (same core).
/// - Within pre-release: identifiers compared element-by-element;
///   numeric < alphanumeric; numeric compared as integers, alphanumeric
///   lexicographically. Shorter prefix wins if equal up to that point.
pub(crate) fn cmp_semver(a: &SemverParts, b: &SemverParts) -> std::cmp::Ordering {
    use std::cmp::Ordering::*;
    // Compare core.
    let core = (a.major, a.minor, a.patch).cmp(&(b.major, b.minor, b.patch));
    if core != Equal {
        return core;
    }
    // Same core: pre-release < release.
    match (a.pre.is_empty(), b.pre.is_empty()) {
        (true, true) => Equal,
        (true, false) => Greater, // release > pre
        (false, true) => Less,    // pre < release
        (false, false) => {
            // Both have pre-release: compare element-by-element.
            let min_len = a.pre.len().min(b.pre.len());
            for i in 0..min_len {
                let ord = cmp_pre_id(&a.pre[i], &b.pre[i]);
                if ord != Equal {
                    return ord;
                }
            }
            // Equal up to min_len; longer wins.
            a.pre.len().cmp(&b.pre.len())
        }
    }
}

fn cmp_pre_id(a: &SemverPreId, b: &SemverPreId) -> std::cmp::Ordering {
    use std::cmp::Ordering::*;
    match (a, b) {
        (SemverPreId::Numeric(x), SemverPreId::Numeric(y)) => x.cmp(y),
        (SemverPreId::Alphanumeric(x), SemverPreId::Alphanumeric(y)) => x.cmp(y),
        (SemverPreId::Numeric(_), SemverPreId::Alphanumeric(_)) => Less, // numeric < alpha
        (SemverPreId::Alphanumeric(_), SemverPreId::Numeric(_)) => Greater,
    }
}

/// Test whether `installed` satisfies `requested` under `strategy`.
///
/// TIER1.md §"Dialect versioning" (line 1257).
pub(crate) fn version_satisfies(
    installed: &SemverParts,
    requested: &SemverParts,
    strategy: VersionStrategy,
) -> bool {
    use std::cmp::Ordering::*;
    match strategy {
        VersionStrategy::Exact => cmp_semver(installed, requested) == Equal,
        VersionStrategy::Caret => {
            if requested.major == 0 {
                // Pre-1.0: same as Tilde (same major.minor, patch >=).
                installed.major == 0
                    && installed.minor == requested.minor
                    && cmp_semver(installed, requested) != Less
            } else {
                installed.major == requested.major
                    && cmp_semver(installed, requested) != Less
            }
        }
        VersionStrategy::Tilde => {
            installed.major == requested.major
                && installed.minor == requested.minor
                && cmp_semver(installed, requested) != Less
        }
        VersionStrategy::Gte => cmp_semver(installed, requested) != Less,
        VersionStrategy::Any => true,
    }
}

/// Build a front-matter-level `DecodeError` with line/col 0.
///
/// `extract_imports` doesn't have a `Parser` instance and doesn't
/// track byte positions through `Value`, so we report position as
/// (0, 0) with a descriptive message that mentions "front matter".
fn fm_err(message: impl Into<String>) -> DecodeError {
    DecodeError { line: 0, column: 0, message: message.into() }
}

/// Extract and validate `_dms_imports` from a tier-1 front-matter map.
///
/// Returns an empty [`ResolvedImports`] when `meta` has no
/// `_dms_imports` key (valid: a tier-1 doc with no dialect imports).
///
/// Rejects (TIER1.md §"Import shape", line 183; §"Sigil validation",
/// line 350; §"Conflict detection — Front-matter-time", line 313):
///
/// - `_dms_imports` not a list, or any entry not a table.
/// - Missing or non-string `dialect` / `version`.
/// - Malformed semver in `version`.
/// - `bind` key that is empty, contains `_`, or has any char outside
///   the tier-0 reserved decorator sigil set.
/// - A family that appears in both `allow` and `deny` within one import.
/// - Two imports that share the same `(sigil, ns, family)` triple.
pub(crate) fn extract_imports(
    meta: &crate::DmsMap<crate::Value>,
) -> Result<ResolvedImports, DecodeError> {
    use crate::Value;

    // Step 1: look up _dms_imports; absent → valid empty imports.
    let raw_list = match meta.get("_dms_imports") {
        None => return Ok(ResolvedImports::default()),
        Some(v) => v,
    };

    // Step 2: must be a list.
    let entries = match raw_list {
        Value::List(v) => v,
        _ => return Err(fm_err("_dms_imports must be a list")),
    };

    let mut specs: Vec<ImportSpec> = Vec::with_capacity(entries.len());
    // Set of (sigil, ns_repr, family) triples seen so far for collision detection.
    // ns_repr is "<unset>" when ns is None.
    let mut seen_triples: std::collections::HashSet<(String, String, String)> =
        std::collections::HashSet::new();

    for (entry_idx, entry) in entries.iter().enumerate() {
        // Each entry must be a table.
        let table = match entry {
            Value::Table(t) => t,
            _ => {
                return Err(fm_err(format!(
                    "_dms_imports[{entry_idx}] must be a table"
                )))
            }
        };

        // ── dialect (required string) ──────────────────────────────────
        let dialect = match table.get("dialect") {
            Some(Value::String(s)) if !s.is_empty() => s.clone(),
            Some(Value::String(_)) => {
                return Err(fm_err(format!(
                    "_dms_imports[{entry_idx}].dialect must be a non-empty string"
                )))
            }
            Some(_) => {
                return Err(fm_err(format!(
                    "_dms_imports[{entry_idx}].dialect must be a string"
                )))
            }
            None => {
                return Err(fm_err(format!(
                    "_dms_imports[{entry_idx}] is missing required field 'dialect'"
                )))
            }
        };

        // ── version (required string, must look like semver) ───────────
        let version = match table.get("version") {
            Some(Value::String(s)) if !s.is_empty() => {
                // Reject range-specifier syntax: ^, ~, >=, >, <, <=, =
                // TIER1.md §"Dialect versioning": plain semver only in files.
                let trimmed = s.trim_start();
                if trimmed.starts_with('^')
                    || trimmed.starts_with('~')
                    || trimmed.starts_with(">=")
                    || trimmed.starts_with('>')
                    || trimmed.starts_with('<')
                    || trimmed.starts_with('=')
                {
                    return Err(fm_err(format!(
                        "range-specifier syntax in version not supported \
                         (_dms_imports[{entry_idx}].version \"{s}\"): write a plain semver string"
                    )));
                }
                if parse_semver(s).is_none() {
                    return Err(fm_err(format!(
                        "_dms_imports[{entry_idx}].version \"{s}\" is not a valid semver string \
                         (expected MAJOR.MINOR.PATCH with optional -pre and +build)"
                    )));
                }
                s.clone()
            }
            Some(Value::String(_)) => {
                return Err(fm_err(format!(
                    "_dms_imports[{entry_idx}].version must be a non-empty string"
                )))
            }
            Some(_) => {
                return Err(fm_err(format!(
                    "_dms_imports[{entry_idx}].version must be a string"
                )))
            }
            None => {
                return Err(fm_err(format!(
                    "_dms_imports[{entry_idx}] is missing required field 'version'"
                )))
            }
        };

        // ── ns (optional string) ───────────────────────────────────────
        let ns: Option<String> = match table.get("ns") {
            None => None,
            Some(Value::String(s)) if s.is_empty() => {
                return Err(fm_err(format!(
                    "_dms_imports[{entry_idx}].ns must be a non-empty string when present"
                )));
            }
            Some(Value::String(s)) => {
                // Validate: must be a valid bare ident
                // (first char: alpha or _; rest: is_bare_key_char).
                let mut chars = s.chars();
                let first = chars.next().unwrap();
                if !crate::is_bare_key_char(first) {
                    return Err(fm_err(format!(
                        "_dms_imports[{entry_idx}].ns \"{s}\" is not a valid identifier"
                    )));
                }
                for c in chars {
                    if !crate::is_bare_key_char(c) {
                        return Err(fm_err(format!(
                            "_dms_imports[{entry_idx}].ns \"{s}\" is not a valid identifier"
                        )));
                    }
                }
                Some(s.clone())
            }
            Some(_) => {
                return Err(fm_err(format!(
                    "_dms_imports[{entry_idx}].ns must be a string"
                )))
            }
        };

        // ── bind (optional table: sigil → list of family names) ────────
        let mut bind: IndexMap<String, Vec<String>> = IndexMap::new();
        if let Some(bind_val) = table.get("bind") {
            let bind_table = match bind_val {
                Value::Table(t) => t,
                _ => {
                    return Err(fm_err(format!(
                        "_dms_imports[{entry_idx}].bind must be a table"
                    )))
                }
            };
            for (sigil, families_val) in bind_table {
                // Validate sigil: non-empty, no underscore, all chars in reserved set.
                if sigil.is_empty() {
                    return Err(fm_err(format!(
                        "_dms_imports[{entry_idx}].bind has an empty sigil key"
                    )));
                }
                // Walk sigil as a sequence of atoms using lex_sigil_atom.
                // Each atom is either an ASCII reserved char or a reserved-emoji
                // grapheme cluster. Anything else (including '_') is a hard error.
                let mut bp = 0usize;
                while bp < sigil.len() {
                    let c = sigil[bp..].chars().next().unwrap();
                    if c == '_' {
                        return Err(fm_err(format!(
                            "_dms_imports[{entry_idx}].bind key \"_\" (or containing '_') \
                             is invalid: underscore is not in the tier-0 reserved decorator \
                             sigil set (TIER1.md §\"Sigil validation\")"
                        )));
                    }
                    if let Some(atom_len) = lex_sigil_atom(&sigil, bp) {
                        bp += atom_len;
                        continue;
                    }
                    return Err(fm_err(format!(
                        "_dms_imports[{entry_idx}].bind key \"{sigil}\" contains '{c}' \
                         (U+{cp:04X}) which is neither in the tier-0 reserved decorator \
                         sigil set nor in the Reserved Emoji Set \
                         (TIER1.md §\"Sigil validation\")",
                        cp = c as u32,
                    )));
                }
                // Value must be a list of strings.
                let families = match families_val {
                    Value::List(lst) => {
                        let mut names = Vec::with_capacity(lst.len());
                        for item in lst {
                            match item {
                                Value::String(s) => names.push(s.clone()),
                                _ => {
                                    return Err(fm_err(format!(
                                        "_dms_imports[{entry_idx}].bind[\"{sigil}\"] must be \
                                         a list of strings"
                                    )))
                                }
                            }
                        }
                        names
                    }
                    _ => {
                        return Err(fm_err(format!(
                            "_dms_imports[{entry_idx}].bind[\"{sigil}\"] must be a list \
                             (use list form even for a single family)"
                        )))
                    }
                };
                bind.insert(sigil.clone(), families);
            }
        }

        // ── allow (optional table: family → list of names) ─────────────
        let mut allow: IndexMap<String, Vec<String>> = IndexMap::new();
        if let Some(allow_val) = table.get("allow") {
            let allow_table = match allow_val {
                Value::Table(t) => t,
                _ => {
                    return Err(fm_err(format!(
                        "_dms_imports[{entry_idx}].allow must be a table"
                    )))
                }
            };
            for (family, names_val) in allow_table {
                let names = extract_string_list(names_val, entry_idx, "allow", family)?;
                allow.insert(family.clone(), names);
            }
        }

        // ── deny (optional table: family → list of names) ──────────────
        let mut deny: IndexMap<String, Vec<String>> = IndexMap::new();
        if let Some(deny_val) = table.get("deny") {
            let deny_table = match deny_val {
                Value::Table(t) => t,
                _ => {
                    return Err(fm_err(format!(
                        "_dms_imports[{entry_idx}].deny must be a table"
                    )))
                }
            };
            for (family, names_val) in deny_table {
                let names = extract_string_list(names_val, entry_idx, "deny", family)?;
                deny.insert(family.clone(), names);
            }
        }

        // ── allow/deny mutual exclusion check ──────────────────────────
        for family in allow.keys() {
            if deny.contains_key(family.as_str()) {
                return Err(fm_err(format!(
                    "_dms_imports[{entry_idx}]: family \"{family}\" appears in both \
                     'allow' and 'deny' — they are mutually exclusive for the same \
                     family (TIER1.md §\"Import shape\", line 245-247)"
                )));
            }
        }

        // ── alias (optional table: family → table: alias → canonical) ──
        let mut alias: IndexMap<String, IndexMap<String, String>> = IndexMap::new();
        if let Some(alias_val) = table.get("alias") {
            let alias_outer = match alias_val {
                Value::Table(t) => t,
                _ => {
                    return Err(fm_err(format!(
                        "_dms_imports[{entry_idx}].alias must be a table"
                    )))
                }
            };
            for (family, inner_val) in alias_outer {
                let inner_table = match inner_val {
                    Value::Table(t) => t,
                    _ => {
                        return Err(fm_err(format!(
                            "_dms_imports[{entry_idx}].alias[\"{family}\"] must be a table \
                             (alias → canonical)"
                        )))
                    }
                };
                let mut inner_map: IndexMap<String, String> = IndexMap::new();
                for (alias_name, canonical_val) in inner_table {
                    match canonical_val {
                        Value::String(s) => {
                            inner_map.insert(alias_name.clone(), s.clone());
                        }
                        _ => {
                            return Err(fm_err(format!(
                                "_dms_imports[{entry_idx}].alias[\"{family}\"][\"{alias_name}\"] \
                                 must be a string (the canonical name)"
                            )));
                        }
                    }
                }
                alias.insert(family.clone(), inner_map);
            }
        }

        // ── Cross-import collision check ────────────────────────────────
        // TIER1.md §"Conflict detection — Front-matter-time" (line 313):
        // same (sigil, ns, family) triple bound by two imports = parse error.
        let ns_repr = ns.as_deref().unwrap_or("<unset>").to_string();
        for (sigil, families) in &bind {
            for family in families {
                let triple = (sigil.clone(), ns_repr.clone(), family.clone());
                if let Some(prev_idx) = seen_triples
                    .iter()
                    .position(|t| t == &triple)
                    .map(|_| {
                        // find which earlier import owned this triple
                        specs.iter().position(|s| {
                            s.bind.get(sigil).map(|fams| fams.contains(family)).unwrap_or(false)
                                && s.ns.as_deref().unwrap_or("<unset>") == ns_repr
                        })
                    })
                    .flatten()
                {
                    let prev = &specs[prev_idx];
                    return Err(fm_err(format!(
                        "Decorator binding collision on (sigil='{sigil}', ns={ns_repr}, \
                         family='{family}'): \
                         import #{prev_idx} dialect '{}' v{} and \
                         import #{entry_idx} dialect '{dialect}' v{version} both bind \
                         '{sigil}' → '{family}'. \
                         Resolve by remapping one.",
                        prev.dialect, prev.version,
                    )));
                }
                seen_triples.insert(triple);
            }
        }

        specs.push(ImportSpec { dialect, version, ns, bind, allow, deny, alias });
    }

    Ok(ResolvedImports { imports: specs })
}

/// Helper: extract a list of strings from a `Value` for allow/deny fields.
fn extract_string_list(
    val: &crate::Value,
    entry_idx: usize,
    field: &str,
    family: &str,
) -> Result<Vec<String>, DecodeError> {
    use crate::Value;
    match val {
        Value::List(lst) => {
            let mut names = Vec::with_capacity(lst.len());
            for item in lst {
                match item {
                    Value::String(s) => names.push(s.clone()),
                    _ => {
                        return Err(fm_err(format!(
                            "_dms_imports[{entry_idx}].{field}[\"{family}\"] must be a \
                             list of strings"
                        )))
                    }
                }
            }
            Ok(names)
        }
        _ => Err(fm_err(format!(
            "_dms_imports[{entry_idx}].{field}[\"{family}\"] must be a list"
        ))),
    }
}

// ── Entry points ──────────────────────────────────────────────────────────────

/// Tier-1 decode entry point. Accepts tier-0 *and* tier-1 input
/// per TIER1.md §"Behavior at the boundary":
///
/// - Tier-0 input → succeeds, returns a [`DocumentT1`] with empty
///   `decorators` and empty `imports`.
/// - Tier-1 input (`_dms_tier: 1`) → parses `_dms_imports` from
///   front matter into [`DocumentT1::imports`]. Body decorator
///   lexing is a future step; `decorators` is empty for now.
///
/// Rejects a tier-0 document that contains `_dms_imports`
/// (TIER1.md §"Front matter additions", line 179-181).
///
/// Mirrors the four-function shape from TIER1.md §"Decoder /
/// encoder split" (`decode_t0` / `encode_t0` / `decode_t1` /
/// `encode_t1`); this is the t1 half of the pair. The tier-0
/// half is [`crate::decode_document`].
pub fn decode_t1(src: &str) -> Result<DocumentT1, DecodeError> {
    decode_t1_with_registry(src, &EmptyRegistry)
}

/// Tier-1 decode with an explicit dialect registry.
///
/// Like [`decode_t1`] but with a registry for family resolution and
/// the hoist pass. With [`EmptyRegistry`], no families are resolved
/// and no hoisting is performed.
///
/// TIER1.md §"Hoist pass" (line 712): after body parse, look up
/// family; if it has a `content_slot` and the call's first Named
/// param group contains that key, move the value into the value
/// tree. §"Conflict: both forms present" (line 733): if the body
/// position is non-empty AND the param key is present → parse error.
pub fn decode_t1_with_registry(
    src: &str,
    registry: &dyn DialectRegistry,
) -> Result<DocumentT1, DecodeError> {
    // Step 1: decode accepting tier-1 front matter.
    let (mut doc, observed_tier, raw_decorations, decoration_only_paths) =
        crate::decode_document_accepting_tier1(src)?;

    // Step 2: extract and validate imports from the front-matter map.
    let meta_ref = doc.meta.as_ref();
    let imports = match meta_ref {
        Some(m) => extract_imports(m)?,
        None => ResolvedImports::default(),
    };

    // Step 3: reject _dms_imports on tier-0 documents (TIER1.md line 179-181).
    if !imports.imports.is_empty() && observed_tier == 0 {
        return Err(DecodeError {
            line: 0,
            column: 0,
            message: "tier-0 documents must not contain _dms_imports; set _dms_tier: 1"
                .to_string(),
        });
    }

    // Step 4: group raw decorations into sidecar entries.
    let mut decorators = group_decorations(raw_decorations);

    // Step 5: family resolution — set call.family from import bind tables.
    for entry in &mut decorators {
        for (_sigil, calls) in entry.decorators.iter_mut() {
            for call in calls.iter_mut() {
                resolve_family(call, &imports, registry)?;
            }
        }
    }

    // Step 5b: decoration-only substitution — for each path where the parser
    // wrote a placeholder (empty Table), replace with the family's empty_default.
    // TIER1.md §"Decoration-only" (line 626).
    for path in &decoration_only_paths {
        // Find the first Inner decorator call at this path.
        let empty_default = decorators
            .iter()
            .find(|e| &e.path == path)
            .and_then(|entry| {
                // Search all sigils in order for the first Inner call.
                entry.decorators.values().flat_map(|calls| calls.iter()).find(|c| {
                    c.position == DecorationPosition::Inner && !c.family.is_empty()
                })
            })
            .and_then(|call| {
                // Find the import that resolved this call's family.
                let import = imports.imports.iter().find(|imp| {
                    imp.bind
                        .get(&call.sigil)
                        .map(|fams| fams.contains(&call.family))
                        .unwrap_or(false)
                })?;
                let dialect_spec = registry.resolve(&import.dialect, &import.version)?;
                let family_spec = dialect_spec.family(&call.family)?;
                Some(family_spec.empty_default.clone())
            });

        // If we resolved an empty_default, substitute it into the body tree.
        if let Some(default_val) = empty_default {
            if path.is_empty() {
                doc.body = default_val;
            } else {
                let (parent_path, last_seg) = path.split_at(path.len() - 1);
                if let Some(parent_val) = body_at_path_mut(&mut doc.body, parent_path) {
                    match (&last_seg[0], parent_val) {
                        (crate::BreadcrumbSegment::Key(k), crate::Value::Table(t)) => {
                            t.insert(k.clone(), default_val);
                        }
                        (crate::BreadcrumbSegment::Index(i), crate::Value::List(l)) => {
                            if *i < l.len() {
                                l[*i] = default_val;
                            }
                        }
                        _ => {}
                    }
                }
            }
        }
        // If no registry match or unresolved family: leave the placeholder
        // (empty Table) in place — tolerated per spec.
    }

    // Step 6: hoist pass — move content_slot params into the value tree.
    hoist_pass(&mut doc.body, &mut decorators, &imports, registry)?;

    // Step 7: param-signature validation pass.
    validate_params_pass(&mut decorators, &imports, registry)?;

    Ok(DocumentT1 { t0: doc, decorators, imports })
}

// ── Family resolution ────────────────────────────────────────────────────────

/// Resolve a decorator call's family per TIER1.md §"Resolution rules".
/// Sets `call.family` and updates `call.fn_name` to canonical (post-alias)
/// form. Errors on:
/// - ns specified but no matching import
/// - canonical-instead-of-alias usage
/// - name not found in any family bound to the sigil
/// - ambiguous match across multiple families
fn resolve_family(
    call: &mut DecoratorCall,
    imports: &ResolvedImports,
    _registry: &dyn DialectRegistry,
) -> Result<(), DecodeError> {
    // Step 1: filter imports by ns qualifier.
    // If call.ns is Some(x), only imports with ns == Some(x) contribute.
    // If call.ns is None, all imports contribute.
    let candidate_imports: Vec<(usize, &ImportSpec)> = if let Some(ns) = &call.ns {
        let filtered: Vec<(usize, &ImportSpec)> = imports
            .imports
            .iter()
            .enumerate()
            .filter(|(_, imp)| imp.ns.as_deref() == Some(ns.as_str()))
            .collect();
        if filtered.is_empty() {
            return Err(fm_err(format!("unknown namespace '{ns}'")));
        }
        filtered
    } else {
        imports.imports.iter().enumerate().collect()
    };

    // Step 2 & 3: for each (import_idx, family_name) candidate, apply
    // alias → allow/deny → collect accepting candidates.
    // Each accepted entry is (import_idx, family_name, canonical_fn_name).
    let mut accepted: Vec<(usize, String, String)> = Vec::new();

    for (import_idx, import) in &candidate_imports {
        // Get the families bound to this sigil in this import.
        let families = match import.bind.get(&call.sigil) {
            Some(f) => f,
            None => continue, // sigil not bound in this import
        };

        for family_name in families {
            // --- Alias resolution ---
            let alias_map = import.alias.get(family_name.as_str());

            let canonical = if let Some(amap) = alias_map {
                if let Some(canonical_name) = amap.get(&call.fn_name) {
                    // fn_name is an alias key → resolved to canonical.
                    canonical_name.clone()
                } else {
                    // Check if fn_name is itself a canonical that has an alias
                    // (i.e., the caller wrote the canonical form when an alias exists).
                    let alias_for_canonical = amap
                        .iter()
                        .find(|(_, canon)| canon.as_str() == call.fn_name)
                        .map(|(alias_key, _)| alias_key.clone());
                    if let Some(alias_key) = alias_for_canonical {
                        return Err(fm_err(format!(
                            "'{}' must be written as '{}' (alias declared in import #{})",
                            call.fn_name, alias_key, import_idx
                        )));
                    }
                    // Not an alias key, not a canonical-with-alias → bare name.
                    call.fn_name.clone()
                }
            } else {
                // No alias map for this family → fn_name is used as-is.
                call.fn_name.clone()
            };

            // --- Allow/deny check ---
            let accepted_by_filter = if let Some(allow_list) = import.allow.get(family_name.as_str()) {
                // allow is specified: canonical must appear in the list.
                allow_list.iter().any(|n| n == &canonical)
            } else if let Some(deny_list) = import.deny.get(family_name.as_str()) {
                // deny is specified: canonical must NOT appear in the list.
                !deny_list.iter().any(|n| n == &canonical)
            } else {
                // Wildcard: any name accepted.
                true
            };

            if accepted_by_filter {
                accepted.push((*import_idx, family_name.clone(), canonical));
            }
        }
    }

    // Step 4: result analysis.
    match accepted.len() {
        0 => Err(fm_err(format!(
            "name '{}' not found in any family bound to sigil '{}'",
            call.fn_name, call.sigil
        ))),
        1 => {
            let (_, family, canonical) = accepted.into_iter().next().unwrap();
            call.family = family;
            call.fn_name = canonical;
            Ok(())
        }
        _ => {
            // Collapse duplicates where the same family is accepted by multiple
            // imports — only truly different family names constitute ambiguity.
            let mut unique_families: Vec<String> = Vec::new();
            for (_, family, canonical) in &accepted {
                if !unique_families.contains(family) {
                    unique_families.push(family.clone());
                }
                // If all accepted entries share the same family + same canonical,
                // treat as unambiguous.
                let _ = canonical;
            }
            if unique_families.len() == 1 {
                // All acceptors agree on the family; use the first's canonical.
                let (_, family, canonical) = accepted.into_iter().next().unwrap();
                call.family = family;
                call.fn_name = canonical;
                Ok(())
            } else {
                let families_str = unique_families
                    .iter()
                    .map(|f| format!("'{f}'"))
                    .collect::<Vec<_>>()
                    .join(", ");
                Err(fm_err(format!(
                    "name '{}' is ambiguous between families {} under sigil '{}'; \
                     qualify as |<ns>.{}(...)",
                    call.fn_name, families_str, call.sigil, call.fn_name
                )))
            }
        }
    }
}

// ── Hoist pass ───────────────────────────────────────────────────────────────

/// Navigate a `Value` tree by path, returning an immutable reference.
///
/// Returns `None` if any segment is missing or the shape doesn't match.
fn body_at_path<'a>(body: &'a crate::Value, path: &[BreadcrumbSegment]) -> Option<&'a crate::Value> {
    let mut cur = body;
    for seg in path {
        match seg {
            BreadcrumbSegment::Key(k) => {
                match cur {
                    crate::Value::Table(t) => cur = t.get(k.as_str())?,
                    _ => return None,
                }
            }
            BreadcrumbSegment::Index(i) => {
                match cur {
                    crate::Value::List(l) => cur = l.get(*i)?,
                    _ => return None,
                }
            }
        }
    }
    Some(cur)
}

/// Navigate a `Value` tree by path, returning a mutable reference.
///
/// Returns `None` if any segment is missing or the shape doesn't match.
fn body_at_path_mut<'a>(
    body: &'a mut crate::Value,
    path: &[BreadcrumbSegment],
) -> Option<&'a mut crate::Value> {
    let mut cur = body;
    for seg in path {
        match seg {
            BreadcrumbSegment::Key(k) => {
                match cur {
                    crate::Value::Table(t) => cur = t.get_mut(k.as_str())?,
                    _ => return None,
                }
            }
            BreadcrumbSegment::Index(i) => {
                match cur {
                    crate::Value::List(l) => cur = l.get_mut(*i)?,
                    _ => return None,
                }
            }
        }
    }
    Some(cur)
}

/// True if `v` represents an absent "indent block" at a path — i.e., there
/// is no block-form content that would conflict with a content_slot hoist.
///
/// An empty table or empty list means the node exists as a placeholder but
/// has no block-form children. A scalar (string, number, boolean, datetime)
/// is treated as "empty" for conflict purposes because scalars cannot be
/// written as indent blocks — they are always inline values that get
/// replaced by the hoist.
///
/// Only a non-empty Table or non-empty List indicates an actual indent block
/// was written in source, which would conflict with the content_slot param.
fn is_value_empty(v: &crate::Value) -> bool {
    match v {
        crate::Value::Table(t) => t.is_empty(),
        crate::Value::List(l) => l.is_empty(),
        // Scalars are inline-only; not an indent block → treated as empty.
        _ => true,
    }
}

/// Run the tier-1 hoist pass.
///
/// For each decoration entry and each call, if the resolved family has a
/// `content_slot` AND the call's first Named param group contains that key:
/// - If the body position at the entry's path is non-empty → conflict error
///   (TIER1.md §"Conflict: both forms present", line 733).
/// - Else → take the slot value out of the param map, set body at the path.
///
/// Calls with unresolved family (empty `call.family`) are skipped.
fn hoist_pass(
    body: &mut crate::Value,
    decorators: &mut [DecoratorEntry],
    imports: &ResolvedImports,
    registry: &dyn DialectRegistry,
) -> Result<(), DecodeError> {
    for entry in decorators.iter_mut() {
        for (_sigil, calls) in entry.decorators.iter_mut() {
            for call in calls.iter_mut() {
                if call.family.is_empty() {
                    continue;
                }

                // Find the import that binds this (sigil, family) pair.
                let import = imports.imports.iter().find(|imp| {
                    imp.bind
                        .get(&call.sigil)
                        .map(|fams| fams.contains(&call.family))
                        .unwrap_or(false)
                });
                let import = match import {
                    Some(i) => i,
                    None => continue,
                };

                // Look up the dialect spec + family spec.
                let dialect_spec = match registry.resolve(&import.dialect, &import.version) {
                    Some(d) => d,
                    None => continue, // registry doesn't know this dialect → skip
                };
                let family_spec = match dialect_spec.family(&call.family) {
                    Some(f) => f,
                    None => continue,
                };

                // Only hoist if the family declares a content_slot.
                let slot = match &family_spec.content_slot {
                    Some(s) => s.clone(),
                    None => continue,
                };

                // Look in the first Named param group for the slot key.
                let slot_value = {
                    let group = call.params.iter_mut().find_map(|g| match g {
                        ParamGroup::Named(m) => Some(m),
                        _ => None,
                    });
                    match group {
                        Some(m) if m.contains_key(slot.as_str()) => {
                            // Key is present; swap_remove returns Some here.
                            m.swap_remove(slot.as_str()).expect("key confirmed by contains_key")
                        }
                        _ => continue, // slot key not present → no hoist
                    }
                };

                // Conflict check: body at entry.path must be empty.
                let body_pos = body_at_path(body, &entry.path);
                let is_empty = body_pos.map(is_value_empty).unwrap_or(true);
                if !is_empty {
                    return Err(DecodeError {
                        line: 0,
                        column: 0,
                        message: format!(
                            "Element |{} has content specified via both '{}:' parameter \
                             and indent block. Pick one.",
                            call.fn_name, slot
                        ),
                    });
                }

                // Hoist: set body at the path.
                if entry.path.is_empty() {
                    *body = slot_value;
                } else {
                    // Navigate to the parent and set the final key/index.
                    let (parent_path, last_seg) = entry.path.split_at(entry.path.len() - 1);
                    let parent = body_at_path_mut(body, parent_path);
                    if let Some(parent_val) = parent {
                        match (&last_seg[0], parent_val) {
                            (BreadcrumbSegment::Key(k), crate::Value::Table(t)) => {
                                t.insert(k.clone(), slot_value);
                            }
                            (BreadcrumbSegment::Index(i), crate::Value::List(l)) => {
                                if *i < l.len() {
                                    l[*i] = slot_value;
                                }
                            }
                            _ => {} // shape mismatch → skip silently
                        }
                    }
                }
            }
        }
    }
    Ok(())
}

// ── Param-signature validation pass ─────────────────────────────────────────

/// Return a friendly, human-readable type label for a `Value` variant.
///
/// Used in validation error messages so callers see `"string"` rather than
/// `"Discriminant(1)"`.
fn value_type_label(v: &crate::Value) -> &'static str {
    use crate::Value;
    match v {
        Value::String(_) => "string",
        Value::Integer(_) => "integer",
        Value::Float(_) => "float",
        Value::Bool(_) => "boolean",
        Value::OffsetDateTime(_) => "offset-datetime",
        Value::LocalDateTime(_) => "local-datetime",
        Value::LocalDate(_) => "local-date",
        Value::LocalTime(_) => "local-time",
        Value::List(_) => "list",
        Value::Table(_) => "table",
        Value::UnorderedTable(_) => "table",
    }
}

/// Type-check a single `value` against a `TypeSpec`.
///
/// Returns `Ok(())` on match, `Err(description)` on mismatch where
/// `description` is a human-readable label of the actual type observed.
///
/// `structs` is the dialect's named-struct list, used for `TypeSpec::Struct`
/// resolution. `seen` is the cycle-detection stack (struct names currently
/// being validated); callers pass `&mut Vec::new()` at the top level.
fn type_check_value(
    value: &crate::Value,
    ty: &TypeSpec,
    structs: &[StructSpec],
    seen: &mut Vec<String>,
) -> Result<(), String> {
    use crate::Value;
    match ty {
        TypeSpec::String => match value {
            Value::String(_) => Ok(()),
            other => Err(value_type_label(other).to_string()),
        },
        TypeSpec::Integer => match value {
            Value::Integer(_) => Ok(()),
            other => Err(value_type_label(other).to_string()),
        },
        TypeSpec::Float => match value {
            Value::Float(_) => Ok(()),
            other => Err(value_type_label(other).to_string()),
        },
        TypeSpec::Boolean => match value {
            Value::Bool(_) => Ok(()),
            other => Err(value_type_label(other).to_string()),
        },
        // lib.rs Value has four datetime variants: OffsetDateTime, LocalDateTime,
        // LocalDate, LocalTime — all satisfy TypeSpec::Datetime.
        TypeSpec::Datetime => match value {
            Value::OffsetDateTime(_)
            | Value::LocalDateTime(_)
            | Value::LocalDate(_)
            | Value::LocalTime(_) => Ok(()),
            other => Err(value_type_label(other).to_string()),
        },
        TypeSpec::ListOf(inner_ty) => match value {
            Value::List(items) => {
                for item in items {
                    type_check_value(item, inner_ty, structs, seen)?;
                }
                Ok(())
            }
            other => Err(value_type_label(other).to_string()),
        },
        TypeSpec::MapOf(inner_ty) => match value {
            Value::Table(map) => {
                for v in map.values() {
                    type_check_value(v, inner_ty, structs, seen)?;
                }
                Ok(())
            }
            other => Err(value_type_label(other).to_string()),
        },
        TypeSpec::Any => Ok(()),
        TypeSpec::Struct(name) => {
            // Cycle detection.
            if seen.iter().any(|s| s == name) {
                return Err(format!("struct cycle: '{}' references itself", name));
            }
            // Value must be a table.
            let map = match value {
                Value::Table(m) => m,
                other => return Err(value_type_label(other).to_string()),
            };
            // Look up the struct spec.
            let struct_spec = match structs.iter().find(|s| &s.name == name) {
                Some(s) => s,
                None => return Err(format!("unknown struct '{}'", name)),
            };
            // Push onto cycle-detection stack.
            seen.push(name.clone());
            // Validate each declared field.
            for (field_name, typed_param) in &struct_spec.fields {
                match map.get(field_name.as_str()) {
                    None => {
                        if typed_param.required {
                            seen.pop();
                            return Err(format!(
                                "struct '{}' missing required field '{}'",
                                name, field_name
                            ));
                        }
                        // Optional absent field: skip.
                    }
                    Some(field_val) => {
                        if let Err(e) =
                            type_check_value(field_val, &typed_param.ty, structs, seen)
                        {
                            seen.pop();
                            return Err(format!(
                                "struct '{}' field '{}': {}",
                                name, field_name, e
                            ));
                        }
                    }
                }
            }
            seen.pop();
            Ok(())
        }
    }
}

/// Format a `TypeSpec` as a human-readable expected-type label for error messages.
fn type_spec_label(ty: &TypeSpec) -> String {
    match ty {
        TypeSpec::String => "string".into(),
        TypeSpec::Integer => "integer".into(),
        TypeSpec::Float => "float".into(),
        TypeSpec::Boolean => "boolean".into(),
        TypeSpec::Datetime => "datetime".into(),
        TypeSpec::ListOf(inner) => format!("list_of({})", type_spec_label(inner)),
        TypeSpec::MapOf(inner) => format!("map_of({})", type_spec_label(inner)),
        TypeSpec::Any => "any".into(),
        TypeSpec::Struct(name) => format!("struct({})", name),
    }
}

/// Run the tier-1 param-signature validation pass per TIER1.md
/// §"Decoder validation behavior" (line 906).
///
/// For each resolved decorator call, look up its family signature.
/// Apply mode-based key validation, type-check declared keys, fill
/// defaults, and check required.
fn validate_params_pass(
    decorators: &mut [DecoratorEntry],
    imports: &ResolvedImports,
    registry: &dyn DialectRegistry,
) -> Result<(), DecodeError> {
    for entry in decorators.iter_mut() {
        for (_sigil, calls) in entry.decorators.iter_mut() {
            for call in calls.iter_mut() {
                // Skip unresolved calls.
                if call.family.is_empty() {
                    continue;
                }

                // Find the import that binds this (sigil, family) pair.
                let import = imports.imports.iter().find(|imp| {
                    imp.bind
                        .get(&call.sigil)
                        .map(|fams| fams.contains(&call.family))
                        .unwrap_or(false)
                });
                let import = match import {
                    Some(i) => i,
                    None => continue,
                };

                // Look up the dialect spec.
                let dialect_spec = match registry.resolve(&import.dialect, &import.version) {
                    Some(d) => d,
                    None => continue, // registry doesn't know this dialect → skip
                };

                // Look up the family spec.
                let family_spec = match dialect_spec.family(&call.family) {
                    Some(f) => f,
                    None => continue,
                };

                let signature = &family_spec.params;
                let mode = signature.mode.unwrap_or(ParamMode::Wildcard);
                let dialect_structs = &dialect_spec.structs;

                // ── Positional group validation ──────────────────────────────
                // In Positional mode, validate positional groups against slots.
                // Named groups are validated below (typed rules apply to both).
                if mode == ParamMode::Positional {
                    let slots = &signature.positional;
                    let n = slots.len();

                    // Validate slot ordering: only the last slot may be variadic.
                    for (i, slot) in slots.iter().enumerate() {
                        if slot.variadic && i + 1 < n {
                            return Err(fm_err(format!(
                                "variadic slot '{}' must be the last positional slot in family '{}'",
                                slot.name, call.family
                            )));
                        }
                    }

                    let last_is_variadic = n > 0 && slots[n - 1].variadic;

                    // Find the positional group, if any.  When none is present
                    // (e.g. `|score()` produces a Named({})) we still need to
                    // validate slots with k = 0.  We pull the positional group
                    // out by index so we can mutate it for default-filling.
                    let pos_group_idx = call.params.iter().position(|g| {
                        matches!(g, ParamGroup::Positional(_))
                    });

                    // A sentinel empty vec used when no positional group exists.
                    // We'll insert it into call.params if needed (for default-filling).
                    let need_sentinel = pos_group_idx.is_none() && n > 0;
                    if need_sentinel {
                        // Insert a Positional([]) as the first group so defaults
                        // can be filled into it.
                        call.params.insert(0, ParamGroup::Positional(Vec::new()));
                    }

                    // Re-find position after possible insertion.
                    let pos_group_idx = call.params.iter().position(|g| {
                        matches!(g, ParamGroup::Positional(_))
                    });

                    if let Some(pg_idx) = pos_group_idx {
                        // Borrow the items vec mutably via index.
                        let k_initial = match &call.params[pg_idx] {
                            ParamGroup::Positional(v) => v.len(),
                            _ => unreachable!(),
                        };

                        if last_is_variadic {
                            // Non-variadic prefix = slots[0..n-1]; variadic = slots[n-1].
                            let prefix_count = n - 1;

                            // Type-check and required-check non-variadic slots.
                            for slot_idx in 0..prefix_count {
                                let k_now = match &call.params[pg_idx] {
                                    ParamGroup::Positional(v) => v.len(),
                                    _ => unreachable!(),
                                };
                                let slot = &slots[slot_idx];
                                if slot_idx < k_now {
                                    // Element present: type-check.
                                    let val = match &call.params[pg_idx] {
                                        ParamGroup::Positional(v) => v[slot_idx].clone(),
                                        _ => unreachable!(),
                                    };
                                    if let Err(e) = type_check_value(
                                        &val,
                                        &slot.ty,
                                        dialect_structs,
                                        &mut Vec::new(),
                                    ) {
                                        return Err(fm_err(format!(
                                            "positional slot '{}' (index {}) in family '{}': expected {}, got {}",
                                            slot.name, slot_idx, call.family,
                                            type_spec_label(&slot.ty), e
                                        )));
                                    }
                                } else {
                                    // Element absent.
                                    if slot.required {
                                        return Err(fm_err(format!(
                                            "positional slot '{}' (index {}) is required in family '{}' but no value provided",
                                            slot.name, slot_idx, call.family
                                        )));
                                    }
                                    // Fill default if present.
                                    if let Some(default_val) = slot.default.clone() {
                                        if let ParamGroup::Positional(v) = &mut call.params[pg_idx] {
                                            while v.len() <= slot_idx {
                                                v.push(default_val.clone());
                                            }
                                        }
                                    }
                                }
                            }

                            // Type-check variadic elements (prefix_count..k).
                            let variadic_slot_ty = slots[n - 1].ty.clone();
                            let variadic_slot_name = slots[n - 1].name.clone();
                            let k_final = match &call.params[pg_idx] {
                                ParamGroup::Positional(v) => v.len(),
                                _ => unreachable!(),
                            };
                            for elem_idx in prefix_count..k_final {
                                let val = match &call.params[pg_idx] {
                                    ParamGroup::Positional(v) => v[elem_idx].clone(),
                                    _ => unreachable!(),
                                };
                                if let Err(e) = type_check_value(
                                    &val,
                                    &variadic_slot_ty,
                                    dialect_structs,
                                    &mut Vec::new(),
                                ) {
                                    return Err(fm_err(format!(
                                        "variadic slot '{}' element {} in family '{}': expected {}, got {}",
                                        variadic_slot_name, elem_idx - prefix_count,
                                        call.family, type_spec_label(&variadic_slot_ty), e
                                    )));
                                }
                            }
                        } else {
                            // No variadic slot.
                            let k = k_initial;

                            // Surplus elements: K > N → error.
                            if k > n {
                                return Err(fm_err(format!(
                                    "too many positional arguments for family '{}': expected at most {}, got {}",
                                    call.family, n, k
                                )));
                            }

                            // Type-check and required-check slots.
                            for slot_idx in 0..n {
                                let k_now = match &call.params[pg_idx] {
                                    ParamGroup::Positional(v) => v.len(),
                                    _ => unreachable!(),
                                };
                                let slot = &slots[slot_idx];
                                if slot_idx < k_now {
                                    let val = match &call.params[pg_idx] {
                                        ParamGroup::Positional(v) => v[slot_idx].clone(),
                                        _ => unreachable!(),
                                    };
                                    if let Err(e) = type_check_value(
                                        &val,
                                        &slot.ty,
                                        dialect_structs,
                                        &mut Vec::new(),
                                    ) {
                                        return Err(fm_err(format!(
                                            "positional slot '{}' (index {}) in family '{}': expected {}, got {}",
                                            slot.name, slot_idx, call.family,
                                            type_spec_label(&slot.ty), e
                                        )));
                                    }
                                } else {
                                    // Element absent.
                                    if slot.required {
                                        return Err(fm_err(format!(
                                            "positional slot '{}' (index {}) is required in family '{}' but no value provided",
                                            slot.name, slot_idx, call.family
                                        )));
                                    }
                                    // Fill default if present.
                                    if let Some(default_val) = slot.default.clone() {
                                        if let ParamGroup::Positional(v) = &mut call.params[pg_idx] {
                                            while v.len() <= slot_idx {
                                                v.push(default_val.clone());
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }

                // ── Named group validation ───────────────────────────────────
                for group in call.params.iter_mut() {
                    let named_map = match group {
                        ParamGroup::Named(m) => m,
                        ParamGroup::Positional(_) => continue, // handled above
                    };

                    match mode {
                        ParamMode::Wildcard => {
                            // Any keys accepted; no checks.
                        }
                        ParamMode::WildcardWithTyped => {
                            // Type-check keys that are declared; unknown keys pass.
                            for (key, value) in named_map.iter() {
                                if let Some(typed_param) = signature.typed.get(key.as_str()) {
                                    if let Err(actual) = type_check_value(
                                        value,
                                        &typed_param.ty,
                                        dialect_structs,
                                        &mut Vec::new(),
                                    ) {
                                        return Err(fm_err(format!(
                                            "param '{}' has type {} but family '{}' signature requires {}",
                                            key, actual, call.family, type_spec_label(&typed_param.ty)
                                        )));
                                    }
                                }
                            }
                        }
                        ParamMode::Strict => {
                            // Only declared typed keys accepted; unknown → error.
                            for (key, value) in named_map.iter() {
                                match signature.typed.get(key.as_str()) {
                                    None => {
                                        return Err(fm_err(format!(
                                            "unknown param '{}' in family '{}' (mode=strict)",
                                            key, call.family
                                        )));
                                    }
                                    Some(typed_param) => {
                                        if let Err(actual) = type_check_value(
                                            value,
                                            &typed_param.ty,
                                            dialect_structs,
                                            &mut Vec::new(),
                                        ) {
                                            return Err(fm_err(format!(
                                                "param '{}' has type {} but family '{}' signature requires {}",
                                                key, actual, call.family, type_spec_label(&typed_param.ty)
                                            )));
                                        }
                                    }
                                }
                            }
                        }
                        ParamMode::Positional => {
                            // Named groups in Positional mode: apply typed rules (same as WildcardWithTyped).
                            for (key, value) in named_map.iter() {
                                if let Some(typed_param) = signature.typed.get(key.as_str()) {
                                    if let Err(actual) = type_check_value(
                                        value,
                                        &typed_param.ty,
                                        dialect_structs,
                                        &mut Vec::new(),
                                    ) {
                                        return Err(fm_err(format!(
                                            "param '{}' has type {} but family '{}' signature requires {}",
                                            key, actual, call.family, type_spec_label(&typed_param.ty)
                                        )));
                                    }
                                }
                            }
                        }
                    }

                    // Apply defaults BEFORE the required check so defaults satisfy required.
                    for (key, typed_param) in &signature.typed {
                        if !named_map.contains_key(key.as_str()) {
                            if let Some(default_val) = &typed_param.default {
                                named_map.insert(key.clone(), default_val.clone());
                            }
                        }
                    }

                    // Required check: keys in signature.required and typed[k].required == true.
                    for key in &signature.required {
                        if !named_map.contains_key(key.as_str()) {
                            return Err(fm_err(format!(
                                "missing required param '{}' in family '{}'",
                                key, call.family
                            )));
                        }
                    }
                    for (key, typed_param) in &signature.typed {
                        if typed_param.required && !named_map.contains_key(key.as_str()) {
                            return Err(fm_err(format!(
                                "missing required param '{}' in family '{}'",
                                key, call.family
                            )));
                        }
                    }
                }
            }
        }
    }
    Ok(())
}

/// Group flat `(path, position, call)` tuples into [`DecoratorEntry`] records.
///
/// Entries are created in source order: the first call seen for a given path
/// creates the entry; subsequent calls for the same path are appended.
/// Within an entry, calls are keyed by sigil into `decorators[sigil]` in
/// source encounter order.
fn group_decorations(
    raw: Vec<(Vec<BreadcrumbSegment>, DecorationPosition, DecoratorCall)>,
) -> Vec<DecoratorEntry> {
    let mut entries: Vec<DecoratorEntry> = Vec::new();

    for (path, position, mut call) in raw {
        // Stamp the position onto the call before storing.
        call.position = position;
        let sigil = call.sigil.clone();

        // Find existing entry for this path, or create one.
        let entry = match entries.iter_mut().find(|e| e.path == path) {
            Some(e) => e,
            None => {
                entries.push(DecoratorEntry {
                    path,
                    decorators: IndexMap::new(),
                    comments: Vec::new(),
                });
                entries.last_mut().unwrap()
            }
        };

        entry.decorators.entry(sigil).or_insert_with(Vec::new).push(call);
    }

    entries
}

/// Tier-1 encode entry point. Accepts tier-0-shaped documents
/// (empty `decorators` and empty `imports`) and tier-1 documents.
///
/// - Empty `decorators` + empty `imports` → emits identical to
///   tier-0 `encode(&doc.t0)` (no `_dms_tier` field injected).
/// - Non-empty `decorators` or non-empty `imports` → emits a
///   valid tier-1 document: `_dms_tier: 1` is injected into the
///   front matter, and decorator calls are emitted at their
///   recorded positions.
///
/// Mirrors [`crate::encode`] for the tier-0 case.
pub fn encode_t1(doc: &DocumentT1) -> Result<String, EncodeError> {
    encode_t1_with_registry(doc, &EmptyRegistry)
}

/// Tier-1 encode with an explicit dialect registry.
///
/// Like [`encode_t1`] but applies the encoder canonical-form heuristics
/// (TIER1.md §"Encoder canonical form"):
///
/// - **Inner decoration-only suppression**: for each Inner-position
///   decorator call at a kvpair/list-item path, if the resolved family
///   has a `content_slot` AND the body value at that path equals the
///   family's `empty_default` (deep equality), the body value is
///   suppressed from output. The encoder emits `key: |dec` (no value
///   token) instead of `key: |dec {}` or similar.
///
/// - **Flow re-fold heuristic**: for each Leading- or Inner-position
///   decorator call at a path where the resolved family has a
///   `content_slot` AND the body is a `List` of all scalars
///   (Integer/Float/String/Bool/datetime variants), the body list is
///   folded into the call's first Named param group as
///   `<content_slot>: <body_list>`, and body emission is suppressed.
///   This produces compact inline forms like `key: |tag(children: ["a"])`.
///
/// - **Leading-position empty_default suppression**: for each
///   Leading-position call at a path where the body matches the family's
///   `empty_default`, the position is shifted to Inner (so no own-line
///   decorator line is emitted) and body emission is suppressed. Combined
///   effect: `|tag()\nkey: {}` → `key: |tag()`. **Lossy round-trip**: the
///   re-decoded document will have an Inner-position call rather than the
///   original Leading position, as tier-0 forbids `key:` with no value and
///   the decoration-only form is the canonical inner-position encoding.
///
/// With [`EmptyRegistry`], no families are resolved so no suppression
/// occurs and the output is identical to `encode_t1`.
pub fn encode_t1_with_registry(
    doc: &DocumentT1,
    registry: &dyn DialectRegistry,
) -> Result<String, EncodeError> {
    if doc.decorators.is_empty() && doc.imports.imports.is_empty() {
        // Tier-0-shaped — round-trip via tier-0 encoder.
        return crate::encode(&doc.t0);
    }
    // Tier-1: build a Document with `_dms_tier: 1` injected into meta,
    // then emit body + decorators.
    let mut meta = doc.t0.meta.clone().unwrap_or_default();
    // Remove any existing _dms_tier key, then re-insert at front.
    meta.shift_remove("_dms_tier");
    meta.shift_insert(0, "_dms_tier".to_string(), crate::Value::Integer(1));
    // `_dms_imports` is preserved in meta by the decoder; trust it.
    let synthetic = crate::Document {
        meta: Some(meta),
        body: doc.t0.body.clone(),
        comments: doc.t0.comments.clone(),
        original_forms: doc.t0.original_forms.clone(),
    };

    // ── Compute canonical-form transforms ────────────────────────────────────
    // Returns a cloned + mutated decorator vec and a suppressed-path set.
    let (canonical_decorators, suppressed_paths) =
        compute_canonical_transforms(&doc.t0.body, &doc.decorators, &doc.imports, registry);

    crate::encode_with_decorators_and_suppressed(&synthetic, &canonical_decorators, suppressed_paths)
}

/// Compute canonical-form transforms for the encoder.
///
/// Returns `(modified_decorators, suppressed_paths)` where:
/// - `modified_decorators` is a clone of `decorators` with mutations applied
///   for flow re-fold (list body folded into call params) and leading→inner
///   position shifts (for leading empty_default suppression).
/// - `suppressed_paths` is the set of paths where body emission is suppressed.
///
/// Three heuristics fire (in priority order):
///
/// 1. **Inner decoration-only suppression** (existing): Inner-position call,
///    `content_slot` present, body == `empty_default` → suppress body.
///
/// 2. **Leading-position empty_default suppression** (new): Leading-position
///    call, `content_slot` present, body == `empty_default` → shift call
///    position to Inner in the cloned decorators AND suppress body. Result:
///    `key: |dec` (decoration-only form, position shifted from Leading to
///    Inner — documented as lossy; see [`encode_t1_with_registry`]).
///
/// 3. **Flow re-fold** (new): Leading- or Inner-position call, `content_slot`
///    present, body is a `List` of all scalars AND the call's params[0] is
///    a `Named` group that does NOT already contain the slot key → fold body
///    into params[0] as `<slot>: <body>` in the cloned decorators AND suppress
///    body. Result: `key: |dec(children: ["a", "b"])`.
///
///    Skips re-fold when params[0] is a `Positional` group (would conflict),
///    when any other Named group already contains the slot key, or when the
///    body List contains any non-scalar element (block form preserved).
fn compute_canonical_transforms(
    body: &crate::Value,
    decorators: &[DecoratorEntry],
    imports: &ResolvedImports,
    registry: &dyn DialectRegistry,
) -> (Vec<DecoratorEntry>, std::collections::HashSet<Vec<crate::BreadcrumbSegment>>) {
    // Fast path: nothing to transform when there are no decorators.
    if decorators.is_empty() {
        return (Vec::new(), std::collections::HashSet::new());
    }
    let mut suppressed: std::collections::HashSet<Vec<crate::BreadcrumbSegment>> =
        std::collections::HashSet::new();
    // Clone decorators — we may mutate individual calls.
    let mut cloned: Vec<DecoratorEntry> = decorators.to_vec();

    for (entry_idx, entry) in decorators.iter().enumerate() {
        // Get the body value at this path.
        let body_val = match body_at_path(body, &entry.path) {
            Some(v) => v,
            None => continue,
        };

        // Collect (sigil, call_idx, call_ref, position) for calls with a
        // resolvable family spec that has a content_slot.
        // We process the first eligible call per entry (highest priority).
        let mut found: Option<(usize, usize, DecorationPosition, &FamilySpec)> = None;

        'outer: for (sigil_key, calls) in &entry.decorators {
            // find the sigil index for mutation later
            let sigil_pos_in_map = entry.decorators.get_index_of(sigil_key.as_str()).unwrap();
            for (call_idx, call) in calls.iter().enumerate() {
                let pos = call.position;
                // Only handle Leading and Inner positions.
                if pos != DecorationPosition::Leading && pos != DecorationPosition::Inner {
                    continue;
                }
                if call.family.is_empty() {
                    continue;
                }
                let family_spec = match find_family_spec(call, imports, registry) {
                    Some(fs) => fs,
                    None => continue,
                };
                if family_spec.content_slot.is_none() {
                    continue;
                }
                found = Some((sigil_pos_in_map, call_idx, pos, family_spec));
                break 'outer;
            }
        }

        let (sigil_idx, call_idx, position, family_spec) = match found {
            Some(t) => t,
            None => continue,
        };
        let slot_key = family_spec.content_slot.as_deref().unwrap();

        // ── Heuristic 1 & 2: empty_default suppression ───────────────────────
        if body_val == &family_spec.empty_default {
            suppressed.insert(entry.path.clone());
            if position == DecorationPosition::Leading {
                // Heuristic 2: shift Leading → Inner in the clone.
                let cloned_calls = cloned[entry_idx]
                    .decorators
                    .get_index_mut(sigil_idx)
                    .unwrap()
                    .1;
                cloned_calls[call_idx].position = DecorationPosition::Inner;
            }
            // (Heuristic 1: Inner already handled — suppressed set suffices.)
            continue;
        }

        // ── Heuristic 3: flow re-fold ─────────────────────────────────────────
        // Only fires when body is a List of all scalars.
        let list_items = match body_val {
            crate::Value::List(items) => items,
            _ => continue,
        };
        if !list_is_all_scalars(list_items) {
            continue;
        }

        // Check params[0]: must be Named (or absent → we create it).
        // Positional groups would conflict; skip.
        let call = &entry.decorators.get_index(sigil_idx).unwrap().1[call_idx];
        let params_ok = if call.params.is_empty() {
            true // will create Named group
        } else {
            match &call.params[0] {
                ParamGroup::Named(m) => !m.contains_key(slot_key),
                ParamGroup::Positional(_) => false,
            }
        };
        if !params_ok {
            continue;
        }
        // Also verify no other group already has the slot key.
        let already_has_slot = call.params.iter().any(|g| {
            if let ParamGroup::Named(m) = g {
                m.contains_key(slot_key)
            } else {
                false
            }
        });
        if already_has_slot {
            continue;
        }

        // Apply the fold: mutate params[0] of the cloned call.
        // Also shift Leading → Inner so the encoder places the decorator
        // inline on the `key:` line (not on its own preceding line).
        let cloned_calls = cloned[entry_idx]
            .decorators
            .get_index_mut(sigil_idx)
            .unwrap()
            .1;
        let cloned_call = &mut cloned_calls[call_idx];
        if position == DecorationPosition::Leading {
            cloned_call.position = DecorationPosition::Inner;
        }
        if cloned_call.params.is_empty() {
            cloned_call.params.push(ParamGroup::Named(crate::DmsMap::default()));
        }
        if let ParamGroup::Named(m) = &mut cloned_call.params[0] {
            m.insert(slot_key.to_string(), body_val.clone());
        }
        suppressed.insert(entry.path.clone());
    }

    (cloned, suppressed)
}

/// Returns `true` when every element in `items` is a scalar variant
/// (Bool, Integer, Float, String, OffsetDateTime, LocalDateTime, LocalDate,
/// LocalTime). Used by the flow re-fold heuristic to detect all-scalar lists.
fn list_is_all_scalars(items: &[crate::Value]) -> bool {
    items.iter().all(|v| {
        matches!(
            v,
            crate::Value::String(_)
                | crate::Value::Integer(_)
                | crate::Value::Float(_)
                | crate::Value::Bool(_)
                | crate::Value::OffsetDateTime(_)
                | crate::Value::LocalDateTime(_)
                | crate::Value::LocalDate(_)
                | crate::Value::LocalTime(_)
        )
    })
}

/// Look up the `FamilySpec` for a resolved decorator call by searching
/// the imports for an import that binds `call.sigil` → `call.family`.
fn find_family_spec<'r>(
    call: &DecoratorCall,
    imports: &ResolvedImports,
    registry: &'r dyn DialectRegistry,
) -> Option<&'r FamilySpec> {
    for imp in &imports.imports {
        if let Some(families) = imp.bind.get(&call.sigil) {
            if families.contains(&call.family) {
                if let Some(dialect_spec) = registry.resolve(&imp.dialect, &imp.version) {
                    if let Some(fs) = dialect_spec.family(&call.family) {
                        return Some(fs);
                    }
                }
            }
        }
    }
    None
}

// ---------------------------------------------------------------------------
// Tier-1 decorator-call lexer
// ---------------------------------------------------------------------------

/// Result of lexing one decorator-call from source.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LexedDecoratorCall {
    /// Maximal run of reserved sigil-set chars (e.g., "|", "||", "|@").
    /// Validated against the file's bound-sigil table at a later step.
    pub sigil: String,
    /// First name token after the sigil (the only name when no dot
    /// follows, or the namespace qualifier when a dotted form is used).
    pub head: String,
    /// Second name token (after `.`); present only for dotted form
    /// `|<head>.<tail>`. Step 3 maps (head, tail) → (ns, fn_name).
    pub tail: Option<String>,
    /// Byte ranges of each `(...)` group's INTERIOR — exclusive of the
    /// open `(` and close `)`. Empty parens produce `(k, k)` (start ==
    /// end). Multiple groups produce one entry per group, in order.
    pub param_group_spans: Vec<(usize, usize)>,
    /// Full byte range of the call from sigil-start to last `)` (or end
    /// of name when no parens).
    pub span: (usize, usize),
}

/// True if `c` is one of the 17 chars in the tier-0 reserved decorator
/// sigil set. Used for sigil-run scanning.
pub(crate) fn is_reserved_sigil_char(c: char) -> bool {
    matches!(c, '!' | '@' | '$' | '%' | '^' | '&' | '*' | '|' | '~' | '`' | '.' | ',' | '>' | '<' | '?' | ';' | '=')
}

/// True if `c` could begin a sigil atom — either an ASCII reserved
/// decorator sigil char OR the first codepoint of a reserved-emoji
/// grapheme cluster. This is the dispatch predicate for line-start /
/// after-`:` / after-`+` / flow-element decoration positions.
pub(crate) fn is_sigil_atom_start(c: char) -> bool {
    is_reserved_sigil_char(c) || crate::is_reserved_emoji_codepoint(c as u32)
}

/// Recognize one sigil atom at byte offset `start` in `src`.
///
/// Returns `Some(byte_len)` if `start` begins a sigil atom — either
/// a single ASCII char from the tier-0 reserved decorator sigil set
/// OR an extended grapheme cluster whose first codepoint is in the
/// Reserved Emoji Set.
/// Returns `None` if no sigil atom starts here.
///
/// Per TIER1.md §"Multi-character sigils" line 146.
pub(crate) fn lex_sigil_atom(src: &str, start: usize) -> Option<usize> {
    let c = src[start..].chars().next()?;
    if is_reserved_sigil_char(c) {
        return Some(c.len_utf8());
    }
    // Check for a reserved-emoji grapheme cluster.
    // `read_reserved_emoji_atom` returns the byte offset of the end of
    // the cluster (i.e. start + cluster_byte_len), or None if no emoji
    // cluster starts here.
    if let Some(end) = crate::read_reserved_emoji_atom(src, start) {
        return Some(end - start);
    }
    None
}

/// Compute (1-based line, 1-based column) for byte offset `pos` in `src`.
fn line_col(src: &str, pos: usize) -> (usize, usize) {
    let prefix = &src[..pos.min(src.len())];
    let line = prefix.bytes().filter(|&b| b == b'\n').count() + 1;
    let col = match prefix.rfind('\n') {
        Some(nl) => prefix.len() - nl,
        None => prefix.len() + 1,
    };
    (line, col)
}

fn make_err(src: &str, pos: usize, msg: &str) -> crate::DecodeError {
    let (line, col) = line_col(src, pos);
    crate::DecodeError { line, column: col, message: msg.to_string() }
}

/// Lex one decorator call starting at byte offset `start` in `src`.
///
/// `start` must point at a reserved-set char. Returns a
/// `LexedDecoratorCall` whose `span.0 == start` and `span.1` is the
/// first byte AFTER the call.
///
/// Errors:
/// - sigil-only (no name follows): "expected decorator name after sigil"
/// - name is empty / starts with non-ident char: "expected decorator name"
/// - dot followed by non-ident: "expected name after `.`"
/// - unclosed `(` (EOF or unbalanced): "unclosed decorator param group"
pub(crate) fn lex_decorator_call(
    src: &str,
    start: usize,
) -> Result<LexedDecoratorCall, crate::DecodeError> {
    let bytes = src.as_bytes();
    let len = bytes.len();
    let mut pos = start;

    // --- 1. Sigil: maximal run of sigil atoms ---
    // A sigil atom is either an ASCII reserved decorator sigil char or
    // one extended grapheme cluster from the Reserved Emoji Set.
    // Atoms of either kind may mix freely within a single sigil
    // (TIER1.md §"Multi-character sigils").
    let sigil_start = pos;
    while let Some(atom_len) = lex_sigil_atom(src, pos) {
        pos += atom_len;
    }
    if pos == sigil_start {
        return Err(make_err(src, pos, "expected decorator name after sigil"));
    }
    let sigil = src[sigil_start..pos].to_string();

    // --- 2. Head name ---
    if pos >= len {
        return Err(make_err(src, pos, "expected decorator name after sigil"));
    }
    let head_start = pos;
    while pos < len {
        let c = src[pos..].chars().next().unwrap();
        if super::is_bare_key_char(c) {
            pos += c.len_utf8();
        } else {
            break;
        }
    }
    if pos == head_start {
        return Err(make_err(src, pos, "expected decorator name"));
    }
    let head = src[head_start..pos].to_string();

    // --- 3. Optional dotted tail ---
    let tail = if pos + 1 < len && bytes[pos] == b'.' {
        // Peek at char after dot
        let after_dot = pos + 1;
        let next_char = src[after_dot..].chars().next();
        if let Some(nc) = next_char {
            if super::is_bare_key_char(nc) {
                pos += 1; // consume '.'
                let tail_start = pos;
                while pos < len {
                    let c = src[pos..].chars().next().unwrap();
                    if super::is_bare_key_char(c) {
                        pos += c.len_utf8();
                    } else {
                        break;
                    }
                }
                if pos == tail_start {
                    return Err(make_err(src, pos, "expected name after `.`"));
                }
                Some(src[tail_start..pos].to_string())
            } else {
                return Err(make_err(src, after_dot, "expected name after `.`"));
            }
        } else {
            None
        }
    } else {
        None
    };

    // --- 4. Param groups: zero or more `(...)` immediately following ---
    let mut param_group_spans: Vec<(usize, usize)> = Vec::new();

    while pos < len && bytes[pos] == b'(' {
        pos += 1; // consume '('
        let interior_start = pos;
        let mut depth: usize = 1;
        let mut in_basic_string = false;
        let mut in_literal_string = false;

        while pos < len {
            let c = src[pos..].chars().next().unwrap();
            let clen = c.len_utf8();

            if in_basic_string {
                if c == '\\' && pos + 1 < len {
                    pos += clen; // skip escape char
                    // skip the escaped char too
                    let escaped = src[pos..].chars().next().unwrap();
                    pos += escaped.len_utf8();
                    continue;
                } else if c == '"' {
                    in_basic_string = false;
                }
                pos += clen;
                continue;
            }

            if in_literal_string {
                if c == '\'' {
                    in_literal_string = false;
                }
                pos += clen;
                continue;
            }

            match c {
                '"' => {
                    in_basic_string = true;
                    pos += clen;
                }
                '\'' => {
                    in_literal_string = true;
                    pos += clen;
                }
                '(' => {
                    depth += 1;
                    pos += clen;
                }
                ')' => {
                    depth -= 1;
                    if depth == 0 {
                        let interior_end = pos;
                        param_group_spans.push((interior_start, interior_end));
                        pos += clen; // consume closing ')'
                        break;
                    }
                    pos += clen;
                }
                _ => {
                    pos += clen;
                }
            }
        }

        if depth != 0 {
            return Err(make_err(src, pos, "unclosed decorator param group"));
        }
    }

    Ok(LexedDecoratorCall {
        sigil,
        head,
        tail,
        param_group_spans,
        span: (start, pos),
    })
}

// ---------------------------------------------------------------------------
// Tier-1 decorator-call parser
// ---------------------------------------------------------------------------

/// Parse one `(start..end)` interior span as a [`ParamGroup`].
///
/// Mode is detected at the first non-whitespace / non-comment token per
/// TIER1.md §"Positional params — Lexer rule":
/// - empty interior → `ParamGroup::Named(empty)`
/// - `ident :` → Named group, reparsed via `{…}`
/// - anything else → Positional group, reparsed via `[…]`
///
/// Returns the group plus any nested decoration entries found on param
/// values. Paths in the returned decoration list are rooted at the
/// group's own shape (`[Key("label")]` for a named group,
/// `[Index(1)]` for a positional group).
/// Returns `true` if the byte slice contains any tier-1 decorator sigil
/// character that could introduce a nested decoration inside a param group.
/// `,` and `.` are intentionally excluded — they appear in normal numeric
/// literals and array separators, causing too many false positives.
#[inline]
fn slice_has_sigil(s: &[u8]) -> bool {
    s.iter().any(|&b| matches!(b,
        b'!' | b'@' | b'$' | b'%' | b'^' | b'&' | b'*'
        | b'|' | b'~' | b'`' | b'>' | b'<' | b'?' | b';' | b'='))
}

fn parse_param_group(
    src: &str,
    start: usize,
    end: usize,
) -> Result<
    (
        ParamGroup,
        Vec<(Vec<crate::BreadcrumbSegment>, DecorationPosition, DecoratorCall)>,
    ),
    crate::DecodeError,
> {
    use crate::Value;

    let slice = &src[start..end];

    // ── Mode detection ────────────────────────────────────────────────────────
    // Skip whitespace and comments to find the first significant token.
    let mut i = 0usize;
    let bytes = slice.as_bytes();
    let slen = slice.len();

    // Returns offset past whitespace + line/block comments starting at `i`.
    let skip_ws_comments = |mut i: usize| -> usize {
        loop {
            // skip whitespace
            while i < slen && (bytes[i] == b' ' || bytes[i] == b'\t' || bytes[i] == b'\r' || bytes[i] == b'\n') {
                i += 1;
            }
            // skip `# ...` line comment
            if i < slen && bytes[i] == b'#' {
                while i < slen && bytes[i] != b'\n' {
                    i += 1;
                }
                continue;
            }
            // skip `// ...` line comment
            if i + 1 < slen && bytes[i] == b'/' && bytes[i + 1] == b'/' {
                while i < slen && bytes[i] != b'\n' {
                    i += 1;
                }
                continue;
            }
            // skip `/* ... */` block comment
            if i + 1 < slen && bytes[i] == b'/' && bytes[i + 1] == b'*' {
                i += 2;
                while i + 1 < slen && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
                    i += 1;
                }
                if i + 1 < slen {
                    i += 2; // consume '*/'
                }
                continue;
            }
            break;
        }
        i
    };

    i = skip_ws_comments(0);

    // Empty interior → empty Named group, no decorations.
    if i >= slen {
        return Ok((ParamGroup::Named(indexmap::IndexMap::new()), Vec::new()));
    }

    // Try to detect `ident :` pattern (Named group).
    // Read a potential ident starting at `i`.
    let ident_start = i;
    while i < slen {
        let c = slice[i..].chars().next().unwrap();
        if crate::is_bare_key_char(c) {
            i += c.len_utf8();
        } else {
            break;
        }
    }
    let ident_end = i;

    // Skip whitespace between potential ident and `:`.
    i = skip_ws_comments(i);

    let is_named = ident_end > ident_start && i < slen && bytes[i] == b':';

    if is_named {
        // Named group: wrap as `{ <interior> }` and parse.
        // Fast path: no sigil chars → no possible nested decorations;
        // use the lighter tier-0 decoder and return empty decs.
        let wrapped = format!("{{{}}}", slice);
        if !slice_has_sigil(slice.as_bytes()) {
            let val = crate::decode(&wrapped).map_err(|e| crate::DecodeError {
                line: e.line,
                column: e.column,
                message: format!("in named param group: {}", e.message),
            })?;
            return match val {
                Value::Table(map) => Ok((ParamGroup::Named(map), Vec::new())),
                other => Err(crate::DecodeError {
                    line: 0,
                    column: 0,
                    message: format!(
                        "named param group decoded to unexpected shape {:?}",
                        other
                    ),
                }),
            };
        }
        let (val, decs) = crate::parse_value_t1(&wrapped).map_err(|e| crate::DecodeError {
            line: e.line,
            column: e.column,
            message: format!("in named param group: {}", e.message),
        })?;
        match val {
            Value::Table(map) => Ok((ParamGroup::Named(map), decs)),
            other => Err(crate::DecodeError {
                line: 0,
                column: 0,
                message: format!(
                    "named param group decoded to unexpected shape {:?}",
                    other
                ),
            }),
        }
    } else {
        // Positional group: wrap as `[ <interior> ]` and parse.
        // Fast path: no sigil chars → no possible nested decorations;
        // use the lighter tier-0 decoder and return empty decs.
        let wrapped = format!("[{}]", slice);
        if !slice_has_sigil(slice.as_bytes()) {
            let val = crate::decode(&wrapped).map_err(|e| crate::DecodeError {
                line: e.line,
                column: e.column,
                message: format!("in positional param group: {}", e.message),
            })?;
            return match val {
                Value::List(items) => Ok((ParamGroup::Positional(items), Vec::new())),
                other => Err(crate::DecodeError {
                    line: 0,
                    column: 0,
                    message: format!(
                        "positional param group decoded to unexpected shape {:?}",
                        other
                    ),
                }),
            };
        }
        let (val, decs) = crate::parse_value_t1(&wrapped).map_err(|e| crate::DecodeError {
            line: e.line,
            column: e.column,
            message: format!("in positional param group: {}", e.message),
        })?;
        match val {
            Value::List(items) => Ok((ParamGroup::Positional(items), decs)),
            other => Err(crate::DecodeError {
                line: 0,
                column: 0,
                message: format!(
                    "positional param group decoded to unexpected shape {:?}",
                    other
                ),
            }),
        }
    }
}

/// Parse one decorator call starting at byte offset `start` in `src`.
///
/// Lexes via [`lex_decorator_call`] then resolves each param-group span
/// into a [`ParamGroup`]. Mode is detected at the first non-whitespace
/// token inside the group per TIER1.md §"Positional params — Lexer rule".
///
/// Returns the parsed call plus the byte offset immediately after the call
/// (= `lexed.span.1`). The caller is responsible for placing the call into
/// the sidecar — this function sets `position = DecorationPosition::Leading`
/// as a stand-in and `family = String::new()` (resolved in step 6).
pub(crate) fn parse_decorator_call(
    src: &str,
    start: usize,
) -> Result<(DecoratorCall, usize), crate::DecodeError> {
    let lexed = lex_decorator_call(src, start)?;

    // Resolve identity: dotted form → (ns, fn_name); bare → (None, head).
    let (ns, fn_name) = match lexed.tail {
        Some(tail) => (Some(lexed.head.clone()), tail),
        None => (None, lexed.head.clone()),
    };

    // family is a stand-in; resolved by step 6 dialect registry.
    let family = String::new();
    let sigil = lexed.sigil.clone();

    // Resolve param groups and collect nested decorations.
    let (params, params_dec): (Vec<ParamGroup>, Vec<DecoratorEntry>) =
        if lexed.param_group_spans.is_empty() {
            // Bare `|tag` — equivalent to `|tag()` per spec.
            (vec![ParamGroup::Named(indexmap::IndexMap::new())], Vec::new())
        } else {
            let mut groups = Vec::with_capacity(lexed.param_group_spans.len());
            // Accumulate raw (path, position, call) tuples from all groups,
            // prepending Index(group_idx) to each path.
            let mut all_raw: Vec<(Vec<crate::BreadcrumbSegment>, DecorationPosition, DecoratorCall)> =
                Vec::new();
            for (group_idx, (gs, ge)) in lexed.param_group_spans.iter().enumerate() {
                let (group, decs) = parse_param_group(src, *gs, *ge)?;
                groups.push(group);
                for (mut path, pos, call) in decs {
                    // Prepend Index(group_idx) so the path is rooted at params.
                    path.insert(0, crate::BreadcrumbSegment::Index(group_idx));
                    all_raw.push((path, pos, call));
                }
            }
            let params_dec = group_decorations(all_raw);
            (groups, params_dec)
        };

    let end = lexed.span.1;
    let call = DecoratorCall {
        sigil,
        family,
        fn_name,
        ns,
        params,
        params_dec,
        position: DecorationPosition::Leading,
    };
    Ok((call, end))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{BreadcrumbSegment, Value};

    /// Front matter for tier-1 documents used in decorator integration tests.
    /// Imports dialect "x" v1.0.0, binds `|` to family "tag".
    const T1_HEADER: &str = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\n";

    #[test]
    fn leading_decorator_attaches_to_following_kvpair() {
        let src = format!("{}|tag()\nkey: 1\n", T1_HEADER);
        let doc = decode_t1(&src).expect("must succeed");
        assert_eq!(doc.decorators.len(), 1, "one entry expected");
        let e = &doc.decorators[0];
        assert_eq!(e.path, vec![BreadcrumbSegment::Key("key".into())]);
        assert_eq!(e.decorators["|"].len(), 1);
        assert_eq!(e.decorators["|"][0].fn_name, "tag");
        assert_eq!(e.decorators["|"][0].position, DecorationPosition::Leading);
    }

    #[test]
    fn multiple_leading_decorators_stack() {
        let src = format!("{}|tag()\n|tag()\nkey: 1\n", T1_HEADER);
        let doc = decode_t1(&src).unwrap();
        assert_eq!(doc.decorators.len(), 1);
        assert_eq!(doc.decorators[0].decorators["|"].len(), 2);
    }

    #[test]
    fn blank_line_makes_decorator_floating() {
        let src = format!("{}key: 1\n|tag()\n\nother: 2\n", T1_HEADER);
        let doc = decode_t1(&src).unwrap();
        // |tag() between `key: 1` and the blank line attaches as Floating
        // on the enclosing root table (path = []).
        let floats: Vec<_> = doc
            .decorators
            .iter()
            .filter(|e| {
                e.decorators
                    .values()
                    .any(|v| v.iter().any(|c| c.position == DecorationPosition::Floating))
            })
            .collect();
        assert_eq!(floats.len(), 1);
        assert_eq!(floats[0].path, Vec::<BreadcrumbSegment>::new());
    }

    #[test]
    fn leading_decorator_at_root_kvpair_table() {
        let src = format!("{}|tag()\na: 1\nb: 2\n", T1_HEADER);
        let doc = decode_t1(&src).unwrap();
        assert_eq!(doc.decorators.len(), 1);
        assert_eq!(doc.decorators[0].path, vec![BreadcrumbSegment::Key("a".into())]);
    }

    #[test]
    fn no_decorators_produces_empty_sidecar() {
        let src = format!("{}a: 1\nb: 2\n", T1_HEADER);
        let doc = decode_t1(&src).unwrap();
        assert!(doc.decorators.is_empty());
    }

    #[test]
    fn decode_t1_on_tier0_input_succeeds_with_empty_decorators() {
        let src = "x: 1\ny: \"hello\"\n";
        let doc = decode_t1(src).expect("tier-0 input must succeed under decode_t1");
        assert!(
            doc.decorators.is_empty(),
            "tier-0 input must produce empty decorators, got {} entries",
            doc.decorators.len()
        );
        let table = match &doc.t0.body {
            Value::Table(t) => t,
            other => panic!("expected table body, got {other:?}"),
        };
        assert_eq!(table["x"], Value::Integer(1));
        assert_eq!(table["y"], Value::String("hello".into()));
    }

    #[test]
    fn decode_t1_passes_through_explicit_tier_zero() {
        let src = "+++\n_dms_tier: 0\n+++\nk: true\n";
        let doc = decode_t1(src).expect("explicit tier-0 must succeed under decode_t1");
        assert!(doc.decorators.is_empty());
        let table = match &doc.t0.body {
            Value::Table(t) => t,
            other => panic!("expected table body, got {other:?}"),
        };
        assert_eq!(table["k"], Value::Bool(true));
    }

    #[test]
    fn document_t0_into_document_t1_zero_decorators() {
        let t0 = crate::decode_document("a: 1\n").unwrap();
        let t1: DocumentT1 = t0.into();
        assert!(t1.decorators.is_empty());
    }

    #[test]
    fn encode_t1_round_trips_tier0_shape() {
        let src = "x: 1\n";
        let doc = decode_t1(src).unwrap();
        let out = encode_t1(&doc).expect("tier-0-shaped DocumentT1 must encode cleanly");
        // Parse the output back to confirm semantic round-trip;
        // exact byte-identity is the encoder's tier-0 contract,
        // not this module's.
        let reparsed = decode_t1(&out).unwrap();
        assert_eq!(reparsed.t0.body, doc.t0.body);
    }

    #[test]
    fn supports_tier1_is_false_in_skeleton() {
        assert!(!SUPPORTS_TIER1, "SUPPORTS_TIER1 should stay false until the t1 decoder lands");
    }

    #[test]
    fn lex_simple_call_no_params() {
        let r = lex_decorator_call("|tag", 0).unwrap();
        assert_eq!(r.sigil, "|");
        assert_eq!(r.head, "tag");
        assert_eq!(r.tail, None);
        assert!(r.param_group_spans.is_empty());
        assert_eq!(r.span, (0, 4));
    }

    #[test]
    fn lex_dotted_call() {
        let r = lex_decorator_call("|html.tag", 0).unwrap();
        assert_eq!(r.sigil, "|");
        assert_eq!(r.head, "html");
        assert_eq!(r.tail, Some("tag".into()));
        assert_eq!(r.span, (0, 9));
    }

    #[test]
    fn lex_multi_char_sigil() {
        let r = lex_decorator_call("||tag", 0).unwrap();
        assert_eq!(r.sigil, "||");
        assert_eq!(r.head, "tag");
    }

    #[test]
    fn lex_call_empty_parens() {
        let r = lex_decorator_call("|tag()", 0).unwrap();
        assert_eq!(r.param_group_spans, vec![(5, 5)]);
        assert_eq!(r.span, (0, 6));
    }

    #[test]
    fn lex_call_one_named_group() {
        let src = "|tag(a: 1)";
        let r = lex_decorator_call(src, 0).unwrap();
        assert_eq!(r.param_group_spans, vec![(5, 9)]);
        assert_eq!(&src[5..9], "a: 1");
    }

    #[test]
    fn lex_call_multi_group() {
        let src = "|tag(a: 1)(b: 2)";
        let r = lex_decorator_call(src, 0).unwrap();
        assert_eq!(r.param_group_spans.len(), 2);
        assert_eq!(&src[r.param_group_spans[0].0..r.param_group_spans[0].1], "a: 1");
        assert_eq!(&src[r.param_group_spans[1].0..r.param_group_spans[1].1], "b: 2");
    }

    #[test]
    fn lex_call_paren_inside_string() {
        let src = r#"|tag(s: "a)b")"#;
        let r = lex_decorator_call(src, 0).unwrap();
        // single group; the ) inside string doesn't close the group
        assert_eq!(r.param_group_spans.len(), 1);
    }

    #[test]
    fn lex_call_nested_parens() {
        let src = "@expr(foo(1))";
        let r = lex_decorator_call(src, 0).unwrap();
        assert_eq!(r.sigil, "@");
        assert_eq!(r.head, "expr");
        assert_eq!(r.param_group_spans.len(), 1);
        assert_eq!(&src[r.param_group_spans[0].0..r.param_group_spans[0].1], "foo(1)");
    }

    #[test]
    fn lex_call_unclosed_paren_errors() {
        let r = lex_decorator_call("|tag(a: 1", 0);
        assert!(r.is_err(), "unclosed paren must be a DecodeError");
    }

    #[test]
    fn lex_call_no_name_errors() {
        let r = lex_decorator_call("|", 0);
        assert!(r.is_err(), "sigil with no name must be a DecodeError");
    }

    #[test]
    fn lex_call_at_offset() {
        // Cursor positioned mid-string (e.g., past key-prefix in real source)
        let src = "key: |tag(a: 1)";
        let r = lex_decorator_call(src, 5).unwrap();
        assert_eq!(r.sigil, "|");
        assert_eq!(r.head, "tag");
        assert_eq!(r.span, (5, 15));
    }

    // ── extract_imports / decode_t1 tests (step 2) ───────────────────────────

    #[test]
    fn decode_t1_accepts_tier1_no_imports() {
        let src = "+++\n_dms_tier: 1\n+++\nx: 1\n";
        let doc = decode_t1(src).expect("tier-1 with no _dms_imports must succeed");
        assert!(doc.imports.imports.is_empty());
        assert!(doc.decorators.is_empty());
    }

    #[test]
    fn decode_t1_rejects_imports_at_tier0() {
        // _dms_imports without _dms_tier: 1 — spec line 179
        let src = "+++\n_dms_imports:\n  + dialect: \"html\"\n    version: \"1.0.0\"\n+++\nx: 1\n";
        let r = decode_t1(src);
        assert!(r.is_err(), "_dms_imports at tier-0 must be a parse error");
    }

    #[test]
    fn extract_imports_minimal() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"html\"\n    version: \"1.0.0\"\n+++\nx: 1\n";
        let doc = decode_t1(src).unwrap();
        assert_eq!(doc.imports.imports.len(), 1);
        assert_eq!(doc.imports.imports[0].dialect, "html");
        assert_eq!(doc.imports.imports[0].version, "1.0.0");
    }

    #[test]
    fn extract_imports_full_shape() {
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    ns: "html"
    bind:
      "|": ["tag", "entity"]
    allow:
      tag: ["p", "div"]
    deny:
      entity: ["amp"]
    alias:
      tag:
        para: "p"
+++
x: 1
"#;
        let doc = decode_t1(src).unwrap();
        let imp = &doc.imports.imports[0];
        assert_eq!(imp.ns, Some("html".into()));
        assert_eq!(imp.bind["|"], vec!["tag".to_string(), "entity".into()]);
        assert_eq!(imp.allow["tag"], vec!["p".to_string(), "div".into()]);
        assert_eq!(imp.deny["entity"], vec!["amp".to_string()]);
        assert_eq!(imp.alias["tag"]["para"], "p");
    }

    #[test]
    fn extract_imports_rejects_bind_with_underscore() {
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "x"
    version: "0.1.0"
    bind:
      "_": ["foo"]
+++
"#;
        assert!(decode_t1(src).is_err());
    }

    #[test]
    fn extract_imports_rejects_bind_outside_reserved_set() {
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "x"
    version: "0.1.0"
    bind:
      "+": ["foo"]
+++
"#;
        assert!(decode_t1(src).is_err());
    }

    #[test]
    fn extract_imports_rejects_allow_deny_overlap() {
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "x"
    version: "0.1.0"
    allow:
      tag: ["p"]
    deny:
      tag: ["b"]
+++
"#;
        assert!(decode_t1(src).is_err(), "allow + deny on same family must error");
    }

    #[test]
    fn extract_imports_rejects_cross_import_collision() {
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    bind:
      "|": ["tag"]
  + dialect: "math"
    version: "0.1.0"
    bind:
      "|": ["tag"]
+++
"#;
        assert!(decode_t1(src).is_err(), "(sigil, ns, family) collision must error");
    }

    #[test]
    fn extract_imports_rejects_invalid_semver() {
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "x"
    version: "not-a-version"
+++
"#;
        assert!(decode_t1(src).is_err());
    }

    #[test]
    fn extract_imports_rejects_missing_dialect() {
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + version: "1.0.0"
+++
"#;
        assert!(decode_t1(src).is_err());
    }

    // ── parse_decorator_call tests (step 3) ──────────────────────────────────

    #[test]
    fn parse_call_bare_no_parens() {
        let (c, end) = parse_decorator_call("|tag", 0).unwrap();
        assert_eq!(c.fn_name, "tag");
        assert_eq!(c.ns, None);
        assert_eq!(c.params.len(), 1);
        match &c.params[0] {
            ParamGroup::Named(m) => assert!(m.is_empty()),
            _ => panic!("bare call must produce one empty Named group"),
        }
        assert_eq!(end, 4);
    }

    #[test]
    fn parse_call_empty_parens() {
        let (c, _) = parse_decorator_call("|tag()", 0).unwrap();
        match &c.params[0] {
            ParamGroup::Named(m) => assert!(m.is_empty()),
            _ => panic!(),
        }
    }

    #[test]
    fn parse_call_dotted_ns() {
        let (c, _) = parse_decorator_call("|html.tag", 0).unwrap();
        assert_eq!(c.fn_name, "tag");
        assert_eq!(c.ns, Some("html".into()));
    }

    #[test]
    fn parse_call_one_named_group() {
        let (c, _) = parse_decorator_call(r#"|tag(class: "lede")"#, 0).unwrap();
        match &c.params[0] {
            ParamGroup::Named(m) => {
                assert_eq!(m.get("class"), Some(&Value::String("lede".into())));
            }
            _ => panic!(),
        }
    }

    #[test]
    fn parse_call_positional_group() {
        let (c, _) = parse_decorator_call("|score(95, 5)", 0).unwrap();
        match &c.params[0] {
            ParamGroup::Positional(v) => {
                assert_eq!(v.len(), 2);
                assert_eq!(v[0], Value::Integer(95));
                assert_eq!(v[1], Value::Integer(5));
            }
            _ => panic!("first-token-non-key:: → positional"),
        }
    }

    #[test]
    fn parse_call_multi_group_mixed() {
        let (c, _) = parse_decorator_call("|score(95, 5)(commented: true)", 0).unwrap();
        assert_eq!(c.params.len(), 2);
        match (&c.params[0], &c.params[1]) {
            (ParamGroup::Positional(_), ParamGroup::Named(_)) => {}
            _ => panic!("expected positional then named"),
        }
    }

    #[test]
    fn parse_call_multi_named_groups() {
        let (c, _) = parse_decorator_call("|tag(a: 1)(b: 2)", 0).unwrap();
        assert_eq!(c.params.len(), 2);
        for g in &c.params {
            assert!(matches!(g, ParamGroup::Named(_)));
        }
    }

    #[test]
    fn parse_call_rejects_mixed_within_group() {
        // 95 is positional (int), then `commented:` is named — mixing within one group
        let r = parse_decorator_call("|x(95, commented: true)", 0);
        assert!(r.is_err(), "mixing positional and named within one group must error");
    }

    #[test]
    fn parse_call_rejects_named_then_positional_within_group() {
        let r = parse_decorator_call("|x(a: 1, 99)", 0);
        assert!(r.is_err(), "after named element, bare value must error");
    }

    #[test]
    fn parse_call_offset() {
        let src = "key: |tag(a: 1) more";
        let (c, end) = parse_decorator_call(src, 5).unwrap();
        assert_eq!(c.fn_name, "tag");
        assert_eq!(end, 15);
    }

    #[test]
    fn parse_call_at_offset_returns_correct_end() {
        let src = "  |meta(charset: \"UTF-8\")";
        let (_c, end) = parse_decorator_call(src, 2).unwrap();
        assert_eq!(end, src.len());
    }

    #[test]
    fn parse_call_string_with_special_chars() {
        let (c, _) = parse_decorator_call(r#"|tag(s: "a, b: c")"#, 0).unwrap();
        match &c.params[0] {
            ParamGroup::Named(m) => {
                assert_eq!(m.get("s"), Some(&Value::String("a, b: c".into())));
            }
            _ => panic!(),
        }
    }

    // ── Dialect-registry type tests (step 5 prep) ────────────────────────────

    #[test]
    fn type_spec_construction() {
        let t = TypeSpec::ListOf(Box::new(TypeSpec::String));
        assert!(matches!(&t, TypeSpec::ListOf(inner) if matches!(**inner, TypeSpec::String)));
    }

    #[test]
    fn family_spec_lookup() {
        let f = FamilySpec {
            name: "tag".into(),
            default_sigils: vec!["|".into()],
            empty_default: Value::Table(Default::default()),
            content_slot: Some("children".into()),
            params: ParamSignature::default(),
        };
        let d = DialectSpec {
            name: "html".into(),
            version: "1.0.0".into(),
            version_strategy: VersionStrategy::default(),
            families: vec![f],
            structs: vec![],
        };
        assert!(d.family("tag").is_some());
        assert!(d.family("missing").is_none());
    }

    #[test]
    fn empty_registry_returns_none() {
        let r = EmptyRegistry;
        assert!(r.resolve("html", "1.0.0").is_none());
    }

    #[test]
    fn in_memory_registry_exact_match() {
        let d = DialectSpec {
            name: "html".into(),
            version: "1.0.0".into(),
            version_strategy: VersionStrategy::Exact,
            families: vec![],
            structs: vec![],
        };
        let r = InMemoryRegistry::new().with(d);
        assert!(r.resolve("html", "1.0.0").is_some());
        assert!(r.resolve("html", "2.0.0").is_none());
        assert!(r.resolve("other", "1.0.0").is_none());
    }

    #[test]
    fn param_signature_default_is_wildcard() {
        let s = ParamSignature::default();
        assert!(s.mode.is_none());
        assert!(s.typed.is_empty());
        assert!(s.required.is_empty());
        assert!(s.positional.is_empty());
    }

    #[test]
    fn struct_spec_field_access() {
        let mut fields = IndexMap::new();
        fields.insert("street".into(), TypedParam {
            ty: TypeSpec::String,
            required: true,
            default: None,
        });
        let s = StructSpec { name: "Address".into(), fields };
        assert_eq!(s.fields.len(), 1);
        assert!(s.fields.contains_key("street"));
    }

    #[test]
    fn positional_slot_variadic_flag() {
        let s = PositionalSlot {
            name: "args".into(),
            ty: TypeSpec::Any,
            required: false,
            default: None,
            variadic: true,
        };
        assert!(s.variadic);
    }

    // ── Inner + Trailing decorator tests (step 4b) ───────────────────────────

    #[test]
    fn inner_decorator_on_kvpair() {
        let src = format!("{}endpoint: |tag() \"/api/users\"\n", T1_HEADER);
        let doc = decode_t1(&src).expect("inner decorator must parse");
        assert_eq!(doc.decorators.len(), 1);
        let e = &doc.decorators[0];
        assert_eq!(e.path, vec![BreadcrumbSegment::Key("endpoint".into())]);
        assert_eq!(e.decorators["|"][0].position, DecorationPosition::Inner);
        // value preserved
        let body = match &doc.t0.body { Value::Table(t) => t, _ => panic!() };
        assert_eq!(body["endpoint"], Value::String("/api/users".into()));
    }

    #[test]
    fn trailing_decorator_on_kvpair() {
        let src = format!("{}port: 5432 |tag()\n", T1_HEADER);
        let doc = decode_t1(&src).unwrap();
        assert_eq!(doc.decorators[0].decorators["|"][0].position, DecorationPosition::Trailing);
        let body = match &doc.t0.body { Value::Table(t) => t, _ => panic!() };
        assert_eq!(body["port"], Value::Integer(5432));
    }

    #[test]
    fn multiple_inner_decorators_stack() {
        let src = format!("{}endpoint: |tag() |tag() \"x\"\n", T1_HEADER);
        let doc = decode_t1(&src).unwrap();
        assert_eq!(doc.decorators[0].decorators["|"].len(), 2);
        for c in &doc.decorators[0].decorators["|"] {
            assert_eq!(c.position, DecorationPosition::Inner);
        }
    }

    #[test]
    fn multiple_trailing_decorators_stack() {
        let src = format!("{}port: 5432 |tag() |tag()\n", T1_HEADER);
        let doc = decode_t1(&src).unwrap();
        assert_eq!(doc.decorators[0].decorators["|"].len(), 2);
        for c in &doc.decorators[0].decorators["|"] {
            assert_eq!(c.position, DecorationPosition::Trailing);
        }
    }

    #[test]
    fn inner_and_trailing_both_present() {
        let src = format!("{}port: |tag() 5432 |tag()\n", T1_HEADER);
        let doc = decode_t1(&src).unwrap();
        let entry = &doc.decorators[0];
        let calls = &entry.decorators["|"];
        assert_eq!(calls.len(), 2);
        let positions: Vec<_> = calls.iter().map(|c| c.position).collect();
        assert!(positions.contains(&DecorationPosition::Inner));
        assert!(positions.contains(&DecorationPosition::Trailing));
    }

    #[test]
    fn leading_inner_trailing_combined() {
        let src = format!("{}|tag()\nport: |tag() 5432 |tag()\n", T1_HEADER);
        let doc = decode_t1(&src).unwrap();
        let calls = &doc.decorators[0].decorators["|"];
        assert_eq!(calls.len(), 3);
        let positions: Vec<_> = calls.iter().map(|c| c.position).collect();
        assert!(positions.contains(&DecorationPosition::Leading));
        assert!(positions.contains(&DecorationPosition::Inner));
        assert!(positions.contains(&DecorationPosition::Trailing));
    }

    // ── Hoist pass + family resolution tests (step 7) ────────────────────────

    fn html_dialect() -> DialectSpec {
        DialectSpec {
            name: "html".into(),
            version: "1.0.0".into(),
            version_strategy: VersionStrategy::default(),
            families: vec![FamilySpec {
                name: "tag".into(),
                default_sigils: vec!["|".into()],
                empty_default: Value::Table(Default::default()),
                content_slot: Some("children".into()),
                params: ParamSignature::default(),
            }],
            structs: vec![],
        }
    }

    #[test]
    fn hoist_children_param_into_body() {
        let registry = InMemoryRegistry::new().with(html_dialect());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    bind:
      "|": ["tag"]
+++
"#;
        let src = format!(
            "{}|tag(class: \"lede\", children: [\"hello\"])\nfoo: 1\n",
            src
        );
        let doc =
            decode_t1_with_registry(&src, &registry).expect("hoist must succeed");
        // After hoist: body[foo] should be the hoisted value.
        let body = match &doc.t0.body {
            Value::Table(t) => t,
            _ => panic!("expected table body"),
        };
        let foo = body.get("foo").expect("foo present");
        match foo {
            Value::List(items) => {
                assert_eq!(items.len(), 1);
                assert_eq!(items[0], Value::String("hello".into()));
            }
            other => panic!("expected list after hoist, got {other:?}"),
        }
        // children key removed; class remains.
        let call = &doc.decorators[0].decorators["|"][0];
        match &call.params[0] {
            ParamGroup::Named(m) => {
                assert!(!m.contains_key("children"));
                assert!(m.contains_key("class"));
            }
            _ => panic!("expected Named param group"),
        }
    }

    #[test]
    fn hoist_conflict_when_block_present() {
        let registry = InMemoryRegistry::new().with(html_dialect());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    bind:
      "|": ["tag"]
+++
"#;
        // body has block-form value at `foo` (non-empty list) AND children: param
        let src = format!("{}|tag(children: [\"a\"])\nfoo:\n  + 1\n  + 2\n", src);
        let r = decode_t1_with_registry(&src, &registry);
        assert!(r.is_err(), "both forms present must error");
        let msg = r.unwrap_err().message;
        assert!(msg.contains("both"), "error message should mention 'both', got: {msg}");
    }

    #[test]
    fn no_hoist_without_registry() {
        // EmptyRegistry — content_slot can't be looked up, so no hoist.
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    bind:
      "|": ["tag"]
+++
"#;
        let src = format!("{}|tag(children: [\"hello\"])\nfoo: 1\n", src);
        let doc = decode_t1(&src).expect("decode_t1 with empty registry should succeed");
        // No hoist: foo stays at 1, children stays in params.
        let body = match &doc.t0.body {
            Value::Table(t) => t,
            _ => panic!(),
        };
        assert_eq!(body["foo"], Value::Integer(1));
        let call = &doc.decorators[0].decorators["|"][0];
        match &call.params[0] {
            ParamGroup::Named(m) => assert!(m.contains_key("children")),
            _ => panic!(),
        }
    }

    #[test]
    fn no_hoist_when_family_has_no_content_slot() {
        let mut d = html_dialect();
        d.families[0].content_slot = None;
        let registry = InMemoryRegistry::new().with(d);
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    bind:
      "|": ["tag"]
+++
"#;
        let src = format!("{}|tag(children: [\"hello\"])\nfoo: 1\n", src);
        let doc = decode_t1_with_registry(&src, &registry).unwrap();
        let body = match &doc.t0.body {
            Value::Table(t) => t,
            _ => panic!(),
        };
        assert_eq!(body["foo"], Value::Integer(1));
    }

    #[test]
    fn family_resolution_sets_family_name() {
        let registry = InMemoryRegistry::new().with(html_dialect());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    bind:
      "|": ["tag"]
+++
"#;
        let src = format!("{}|tag()\nfoo: 1\n", src);
        let doc = decode_t1_with_registry(&src, &registry).unwrap();
        assert_eq!(doc.decorators[0].decorators["|"][0].family, "tag");
    }

    // ── encode_t1 round-trip tests ────────────────────────────────────────────

    #[test]
    fn encode_t1_tier0_shape_roundtrips_unchanged() {
        let src = "x: 1\ny: \"hello\"\n";
        let doc = decode_t1(src).unwrap();
        let out = encode_t1(&doc).unwrap();
        let reparsed = decode_t1(&out).unwrap();
        assert_eq!(reparsed.t0.body, doc.t0.body);
    }

    #[test]
    fn encode_t1_with_leading_decorator_roundtrips() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\n|tag()\nfoo: 1\n";
        let doc = decode_t1(src).unwrap();
        let out = encode_t1(&doc).unwrap();
        let r2 = decode_t1(&out).unwrap();
        assert_eq!(r2.decorators.len(), 1);
        assert_eq!(r2.decorators[0].path, vec![BreadcrumbSegment::Key("foo".into())]);
        assert_eq!(r2.decorators[0].decorators["|"][0].fn_name, "tag");
        assert_eq!(r2.decorators[0].decorators["|"][0].position, DecorationPosition::Leading);
        assert_eq!(r2.t0.body, doc.t0.body);
    }

    #[test]
    fn encode_t1_inner_decorator_roundtrips() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nendpoint: |tag() \"/api\"\n";
        let doc = decode_t1(src).unwrap();
        let out = encode_t1(&doc).unwrap();
        let r2 = decode_t1(&out).unwrap();
        let calls = &r2.decorators[0].decorators["|"];
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].position, DecorationPosition::Inner);
    }

    #[test]
    fn encode_t1_trailing_decorator_roundtrips() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nport: 5432 |tag()\n";
        let doc = decode_t1(src).unwrap();
        let out = encode_t1(&doc).unwrap();
        let r2 = decode_t1(&out).unwrap();
        let calls = &r2.decorators[0].decorators["|"];
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].position, DecorationPosition::Trailing);
    }

    #[test]
    fn encode_t1_decorator_with_named_params() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\n|tag(class: \"lede\")\nfoo: 1\n";
        let doc = decode_t1(src).unwrap();
        let out = encode_t1(&doc).unwrap();
        let r2 = decode_t1(&out).unwrap();
        let call = &r2.decorators[0].decorators["|"][0];
        match &call.params[0] {
            ParamGroup::Named(m) => {
                assert_eq!(m["class"], Value::String("lede".into()));
            }
            _ => panic!("expected named group"),
        }
    }

    #[test]
    fn encode_t1_decorator_with_positional_params() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"score\"]\n+++\nresult: 95 |score(95, 5)\n";
        let doc = decode_t1(src).unwrap();
        let out = encode_t1(&doc).unwrap();
        let r2 = decode_t1(&out).unwrap();
        let call = &r2.decorators[0].decorators["|"][0];
        match &call.params[0] {
            ParamGroup::Positional(items) => {
                assert_eq!(items, &vec![Value::Integer(95), Value::Integer(5)]);
            }
            _ => panic!("expected positional group"),
        }
    }

    // ── encode_t1_with_registry canonical-form heuristic tests ─────────────

    fn html_dialect_tag_only() -> DialectSpec {
        DialectSpec {
            name: "html".into(),
            version: "1.0.0".into(),
            version_strategy: VersionStrategy::default(),
            families: vec![FamilySpec {
                name: "tag".into(),
                default_sigils: vec!["|".into()],
                empty_default: crate::Value::Table(crate::DmsMap::default()),
                content_slot: Some("children".into()),
                params: ParamSignature::default(),
            }],
            structs: vec![],
        }
    }

    fn validators_dialect_for_encoder() -> DialectSpec {
        DialectSpec {
            name: "validators".into(),
            version: "1.0.0".into(),
            version_strategy: VersionStrategy::default(),
            families: vec![FamilySpec {
                name: "rule".into(),
                default_sigils: vec!["|".into()],
                empty_default: crate::Value::Bool(true),
                content_slot: Some("value".into()),
                params: ParamSignature::default(),
            }],
            structs: vec![],
        }
    }

    /// Decoder reads `self_close: |tag(name: "meta")` → body gets empty Table
    /// (empty_default). Encoder with registry should suppress the body and
    /// round-trip back to `self_close: |tag(name: "meta")` (no `: {}` on the
    /// output line).
    #[test]
    fn encode_t1_with_registry_suppresses_empty_default_kvpair() {
        let registry = InMemoryRegistry::new().with(html_dialect_tag_only());
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"html\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nself_close: |tag(name: \"meta\")\n";
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        let out = encode_t1_with_registry(&doc, &registry).unwrap();
        // Output must NOT contain the `: {}` empty-default body emission.
        assert!(
            !out.contains(": {}"),
            "encoded output must not contain empty default body: {out:?}"
        );
        // The decorator call must still be present.
        assert!(out.contains("|tag(name: \"meta\")"), "decorator must be preserved: {out:?}");
        // Round-trip: decode again and check body + decorator survive.
        let r2 = decode_t1_with_registry(&out, &registry).unwrap();
        let body = match &r2.t0.body {
            crate::Value::Table(t) => t,
            _ => panic!("expected table body"),
        };
        assert!(matches!(body["self_close"], crate::Value::Table(ref t) if t.is_empty()));
        assert_eq!(r2.decorators[0].decorators["|"][0].fn_name, "tag");
        assert_eq!(r2.decorators[0].decorators["|"][0].position, DecorationPosition::Inner);
    }

    /// Same as above but for a list-item context: `+ |tag(name: "br")`.
    #[test]
    fn encode_t1_with_registry_suppresses_empty_default_list_item() {
        let registry = InMemoryRegistry::new().with(html_dialect_tag_only());
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"html\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\n+ |tag(name: \"br\")\n+ |tag(name: \"hr\")\n";
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        let out = encode_t1_with_registry(&doc, &registry).unwrap();
        assert!(!out.contains("{}"), "no empty-default body should appear: {out:?}");
        assert!(out.contains("|tag(name: \"br\")"), "first decorator must be present");
        assert!(out.contains("|tag(name: \"hr\")"), "second decorator must be present");
        // Verify round-trip: two list items with Inner decorators survive.
        let r2 = decode_t1_with_registry(&out, &registry).unwrap();
        assert_eq!(r2.decorators.len(), 2);
        for entry in &r2.decorators {
            assert_eq!(entry.decorators["|"][0].position, DecorationPosition::Inner);
        }
    }

    /// Without registry, `encode_t1_with_registry(&doc, &EmptyRegistry)` must
    /// NOT suppress — no family info available, so body is emitted as-is.
    #[test]
    fn encode_t1_with_registry_no_suppression_without_registry() {
        // Decode with registry so body gets empty_default substituted.
        let registry = InMemoryRegistry::new().with(html_dialect_tag_only());
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"html\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nself_close: |tag(name: \"meta\")\n";
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        // Encode without registry — body `{}` should be emitted verbatim.
        let out = encode_t1_with_registry(&doc, &EmptyRegistry).unwrap();
        assert!(out.contains("{}"), "without registry, empty-default body must be emitted: {out:?}");
    }

    /// Family with content_slot=Some but body does NOT equal empty_default
    /// (body has actual content) — must NOT suppress.
    #[test]
    fn encode_t1_with_registry_no_suppression_when_body_nonempty() {
        let registry = InMemoryRegistry::new().with(validators_dialect_for_encoder());
        // Body at `active` will be Bool(false) after hoist/decode — not matching
        // empty_default Bool(true), so no suppression.
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"validators\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"rule\"]\n+++\nactive: |rule() false\n";
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        // Body at `active` is Bool(false); empty_default is Bool(true) → no match.
        let body = match &doc.t0.body {
            crate::Value::Table(t) => t,
            _ => panic!("expected table"),
        };
        assert_eq!(body["active"], crate::Value::Bool(false));
        let out = encode_t1_with_registry(&doc, &registry).unwrap();
        // false must appear in output (not suppressed).
        assert!(out.contains("false"), "non-matching body must not be suppressed: {out:?}");
    }

    // ── flow re-fold + leading empty_default suppression tests ───────────────

    fn list_dialect() -> DialectSpec {
        DialectSpec {
            name: "html".into(),
            version: "1.0.0".into(),
            version_strategy: VersionStrategy::default(),
            families: vec![FamilySpec {
                name: "tag".into(),
                default_sigils: vec!["|".into()],
                empty_default: crate::Value::List(vec![]),
                content_slot: Some("children".into()),
                params: ParamSignature::default(),
            }],
            structs: vec![],
        }
    }

    /// Flow re-fold: Leading-position `|tag()` on a kvpair whose body is a
    /// single-element scalar List — encoder folds into `children:` param and
    /// suppresses body emission. Round-trip preserves the list semantics.
    #[test]
    fn flow_refold_short_inline_span() {
        let registry = InMemoryRegistry::new().with(list_dialect());
        // After decode (with hoist), foo's body becomes ["text"].
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"html\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\n|tag(class: \"lede\")\nfoo:\n  + \"text\"\n";
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        let out = encode_t1_with_registry(&doc, &registry).unwrap();
        // Expect re-emitted form contains 'children:' inline (flow-re-fold) and no
        // block-form `+ "text"` for foo.
        assert!(
            out.contains("children:") || out.contains("foo: |"),
            "expected flow re-fold; got:\n{}",
            out
        );
        // Round-trip: re-decode, semantically equal body.
        let r2 = decode_t1_with_registry(&out, &registry).unwrap();
        // After re-decode (hoist re-runs), body should match original (List of one string).
        let body = match &r2.t0.body {
            crate::Value::Table(t) => t,
            _ => panic!("expected table body"),
        };
        match body.get("foo") {
            Some(crate::Value::List(items)) => {
                assert_eq!(items, &vec![crate::Value::String("text".into())])
            }
            other => panic!("expected list at foo, got {other:?}"),
        }
    }

    /// Flow re-fold: multi-element scalar list round-trips correctly.
    #[test]
    fn flow_refold_multi_scalar_list() {
        let registry = InMemoryRegistry::new().with(list_dialect());
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"html\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\n|tag()\nfoo:\n  + \"a\"\n  + \"b\"\n";
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        let out = encode_t1_with_registry(&doc, &registry).unwrap();
        let r2 = decode_t1_with_registry(&out, &registry).unwrap();
        let body = match &r2.t0.body {
            crate::Value::Table(t) => t,
            _ => panic!("expected table body"),
        };
        match body.get("foo") {
            Some(crate::Value::List(items)) => assert_eq!(items.len(), 2),
            other => panic!("expected 2-element list at foo, got {other:?}"),
        }
    }

    /// Flow re-fold skip: body is a List but contains a Table element (non-scalar)
    /// → block form preserved, no `children:` in output.
    #[test]
    fn flow_refold_skips_when_element_is_table() {
        let registry = InMemoryRegistry::new().with(list_dialect());
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"html\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\n|tag()\nfoo:\n  +\n    a: 1\n";
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        let out = encode_t1_with_registry(&doc, &registry).unwrap();
        // Should NOT contain children: (block form preserved)
        assert!(
            !out.contains("children:"),
            "table element should not flow-re-fold; got:\n{}",
            out
        );
        let r2 = decode_t1_with_registry(&out, &registry).unwrap();
        let body = match &r2.t0.body {
            crate::Value::Table(t) => t,
            _ => panic!("expected table body"),
        };
        match body.get("foo") {
            Some(crate::Value::List(items)) => assert_eq!(items.len(), 1),
            other => panic!("expected 1-element list at foo, got {other:?}"),
        }
    }

    /// Leading-position empty_default suppression: `|tag()\nfoo: {}` → `foo: |tag()`.
    /// Position shifts Leading→Inner (documented lossy round-trip in encoder docstring).
    #[test]
    fn leading_empty_default_shifts_to_inner() {
        fn meta_dialect() -> DialectSpec {
            DialectSpec {
                name: "html".into(),
                version: "1.0.0".into(),
                version_strategy: VersionStrategy::default(),
                families: vec![FamilySpec {
                    name: "tag".into(),
                    default_sigils: vec!["|".into()],
                    empty_default: crate::Value::Table(crate::DmsMap::default()),
                    content_slot: Some("children".into()),
                    params: ParamSignature::default(),
                }],
                structs: vec![],
            }
        }
        let registry = InMemoryRegistry::new().with(meta_dialect());
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"html\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\n|tag()\nfoo: {}\n";
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        let out = encode_t1_with_registry(&doc, &registry).unwrap();
        // Should shift Leading→Inner: emit `foo: |tag()` (no `: {}`).
        assert!(
            !out.contains(": {}"),
            "empty-default body must be suppressed; got:\n{}",
            out
        );
        // Encoder emits bare form |tag (no parens) or |tag() — both are valid.
        assert!(
            out.contains("|tag"),
            "decorator call must appear in output; got:\n{}",
            out
        );
        let r2 = decode_t1_with_registry(&out, &registry).unwrap();
        let entry = &r2.decorators[0];
        assert_eq!(entry.path, vec![BreadcrumbSegment::Key("foo".into())]);
        let calls = &entry.decorators["|"];
        assert_eq!(calls.len(), 1);
        // After re-decode, position is Inner (decoration-only form).
        assert_eq!(calls[0].position, DecorationPosition::Inner);
    }

    // ── Decoration-only tests (TIER1.md §"Decoration-only") ──────────────────

    fn html_dialect_with_meta() -> DialectSpec {
        DialectSpec {
            name: "html".into(),
            version: "1.0.0".into(),
            version_strategy: VersionStrategy::default(),
            families: vec![FamilySpec {
                name: "tag".into(),
                default_sigils: vec!["|".into()],
                empty_default: Value::Table(crate::DmsMap::default()),
                content_slot: Some("children".into()),
                params: ParamSignature::default(),
            }],
            structs: vec![],
        }
    }

    fn validators_dialect() -> DialectSpec {
        DialectSpec {
            name: "validators".into(),
            version: "1.0.0".into(),
            version_strategy: VersionStrategy::default(),
            families: vec![FamilySpec {
                name: "rule".into(),
                default_sigils: vec!["|".into()],
                empty_default: Value::Bool(true),
                content_slot: None,
                params: ParamSignature::default(),
            }],
            structs: vec![],
        }
    }

    #[test]
    fn decoration_only_kvpair_uses_empty_default() {
        let registry = InMemoryRegistry::new().with(html_dialect_with_meta());
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"html\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nself_close: |tag(name: \"meta\")\n";
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        let body = match &doc.t0.body {
            Value::Table(t) => t,
            _ => panic!("expected table body"),
        };
        match &body["self_close"] {
            Value::Table(t) => assert!(t.is_empty(), "expected empty table from empty_default"),
            _ => panic!("expected empty table at self_close"),
        }
        let entry = &doc.decorators[0];
        assert_eq!(entry.path, vec![BreadcrumbSegment::Key("self_close".into())]);
        assert_eq!(entry.decorators["|"][0].position, DecorationPosition::Inner);
    }

    #[test]
    fn decoration_only_list_item_uses_empty_default() {
        let registry = InMemoryRegistry::new().with(html_dialect_with_meta());
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"html\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\n+ |tag(name: \"meta\")\n+ |tag(name: \"link\")\n";
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        let body = match &doc.t0.body {
            Value::List(l) => l,
            _ => panic!("expected list body"),
        };
        assert_eq!(body.len(), 2);
        for item in body {
            match item {
                Value::Table(t) => assert!(t.is_empty(), "expected empty table from empty_default"),
                _ => panic!("expected empty table for each list item"),
            }
        }
        assert_eq!(doc.decorators.len(), 2);
    }

    #[test]
    fn decoration_only_with_non_table_empty_default() {
        let registry = InMemoryRegistry::new().with(validators_dialect());
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"validators\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"rule\"]\n+++\nactive: |rule\n";
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        let body = match &doc.t0.body {
            Value::Table(t) => t,
            _ => panic!("expected table body"),
        };
        assert_eq!(body["active"], Value::Bool(true), "empty_default for rule family is Bool(true)");
    }

    #[test]
    fn decoration_only_without_registry_keeps_placeholder() {
        // No registry → no empty_default lookup → placeholder stays as empty Table.
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"html\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nself_close: |tag(name: \"meta\")\n";
        let doc = decode_t1(src).unwrap();
        let body = match &doc.t0.body {
            Value::Table(t) => t,
            _ => panic!("expected table body"),
        };
        match &body["self_close"] {
            Value::Table(t) => assert!(t.is_empty(), "placeholder must be empty table"),
            _ => panic!("expected empty table placeholder"),
        }
    }

    // ── Resolution rules: alias / allow / deny / ambiguity tests ────────────

    fn html_dialect_two_families() -> DialectSpec {
        DialectSpec {
            name: "html".into(),
            version: "1.0.0".into(),
            version_strategy: VersionStrategy::default(),
            families: vec![
                FamilySpec {
                    name: "tag".into(),
                    default_sigils: vec!["|".into()],
                    empty_default: Value::Table(Default::default()),
                    content_slot: Some("children".into()),
                    params: ParamSignature::default(),
                },
                FamilySpec {
                    name: "entity".into(),
                    default_sigils: vec!["|".into()],
                    empty_default: Value::String("".into()),
                    content_slot: None,
                    params: ParamSignature::default(),
                },
            ],
            structs: vec![],
        }
    }

    #[test]
    fn resolution_alias_rewrites_fn_name() {
        let registry = InMemoryRegistry::new().with(html_dialect_two_families());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    bind:
      "|": ["tag"]
    alias:
      tag:
        para: "p"
+++
foo: |para()
"#;
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        let call = &doc.decorators[0].decorators["|"][0];
        assert_eq!(call.fn_name, "p", "alias 'para' should rewrite to canonical 'p'");
        assert_eq!(call.family, "tag");
    }

    #[test]
    fn resolution_canonical_when_alias_exists_errors() {
        let registry = InMemoryRegistry::new().with(html_dialect_two_families());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    bind:
      "|": ["tag"]
    alias:
      tag:
        para: "p"
+++
foo: |p()
"#;
        let r = decode_t1_with_registry(src, &registry);
        assert!(r.is_err(), "writing canonical 'p' when alias 'para' exists must error");
    }

    #[test]
    fn resolution_allow_accepts_listed() {
        let registry = InMemoryRegistry::new().with(html_dialect_two_families());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    bind:
      "|": ["tag"]
    allow:
      tag: ["p", "div"]
+++
foo: |p()
"#;
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        assert_eq!(doc.decorators[0].decorators["|"][0].fn_name, "p");
    }

    #[test]
    fn resolution_allow_rejects_unlisted() {
        let registry = InMemoryRegistry::new().with(html_dialect_two_families());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    bind:
      "|": ["tag"]
    allow:
      tag: ["p", "div"]
+++
foo: |script()
"#;
        let r = decode_t1_with_registry(src, &registry);
        assert!(r.is_err(), "name not in allow list must error");
    }

    #[test]
    fn resolution_deny_rejects_listed() {
        let registry = InMemoryRegistry::new().with(html_dialect_two_families());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    bind:
      "|": ["tag"]
    deny:
      tag: ["script", "iframe"]
+++
foo: |script()
"#;
        let r = decode_t1_with_registry(src, &registry);
        assert!(r.is_err(), "name in deny list must error");
    }

    #[test]
    fn resolution_deny_accepts_unlisted() {
        let registry = InMemoryRegistry::new().with(html_dialect_two_families());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    bind:
      "|": ["tag"]
    deny:
      tag: ["script"]
+++
foo: |p()
"#;
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        assert_eq!(doc.decorators[0].decorators["|"][0].fn_name, "p");
    }

    #[test]
    fn resolution_wildcard_accepts_anything() {
        let registry = InMemoryRegistry::new().with(html_dialect_two_families());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    bind:
      "|": ["tag"]
+++
foo: |arbitrary_name()
"#;
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        assert_eq!(doc.decorators[0].decorators["|"][0].fn_name, "arbitrary_name");
        assert_eq!(doc.decorators[0].decorators["|"][0].family, "tag");
    }

    #[test]
    fn resolution_ns_qualified_call() {
        let registry = InMemoryRegistry::new().with(html_dialect_two_families());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    ns: "html"
    bind:
      "|": ["tag"]
+++
foo: |html.p()
"#;
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        let call = &doc.decorators[0].decorators["|"][0];
        assert_eq!(call.ns, Some("html".into()));
        assert_eq!(call.family, "tag");
    }

    #[test]
    fn resolution_unknown_ns_errors() {
        let registry = InMemoryRegistry::new().with(html_dialect_two_families());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    bind:
      "|": ["tag"]
+++
foo: |unknown.p()
"#;
        let r = decode_t1_with_registry(src, &registry);
        assert!(r.is_err());
    }

    // ── Param-signature validation tests (step 7 — validate_params_pass) ────────

    fn dialect_with_typed_signature() -> DialectSpec {
        let mut typed = IndexMap::new();
        typed.insert("class".into(), TypedParam {
            ty: TypeSpec::String,
            required: false,
            default: None,
        });
        typed.insert("hidden".into(), TypedParam {
            ty: TypeSpec::Boolean,
            required: false,
            default: Some(Value::Bool(false)),
        });
        typed.insert("count".into(), TypedParam {
            ty: TypeSpec::Integer,
            required: true,
            default: None,
        });
        DialectSpec {
            name: "ui".into(),
            version: "1.0.0".into(),
            version_strategy: VersionStrategy::default(),
            families: vec![
                FamilySpec {
                    name: "widget".into(),
                    default_sigils: vec!["|".into()],
                    empty_default: Value::Table(Default::default()),
                    content_slot: None,
                    params: ParamSignature {
                        mode: Some(ParamMode::WildcardWithTyped),
                        typed,
                        required: vec![],  // count.required=true is enough
                        positional: vec![],
                    },
                },
            ],
            structs: vec![],
        }
    }

    fn dialect_strict() -> DialectSpec {
        let mut d = dialect_with_typed_signature();
        d.families[0].params.mode = Some(ParamMode::Strict);
        d
    }

    #[test]
    fn validate_typed_correct_types_passes() {
        let registry = InMemoryRegistry::new().with(dialect_with_typed_signature());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "ui"
    version: "1.0.0"
    bind:
      "|": ["widget"]
+++
foo: |widget(class: "x", count: 5)
"#;
        decode_t1_with_registry(src, &registry).expect("typed correct types pass");
    }

    #[test]
    fn validate_typed_wrong_type_errors() {
        let registry = InMemoryRegistry::new().with(dialect_with_typed_signature());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "ui"
    version: "1.0.0"
    bind:
      "|": ["widget"]
+++
foo: |widget(class: 123, count: 5)
"#;
        let r = decode_t1_with_registry(src, &registry);
        assert!(r.is_err(), "string type with int value must error");
        let msg = r.unwrap_err().message;
        assert!(msg.contains("class") || msg.contains("string"));
    }

    #[test]
    fn validate_required_missing_errors() {
        let registry = InMemoryRegistry::new().with(dialect_with_typed_signature());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "ui"
    version: "1.0.0"
    bind:
      "|": ["widget"]
+++
foo: |widget(class: "x")
"#;
        let r = decode_t1_with_registry(src, &registry);
        assert!(r.is_err(), "missing required 'count' must error");
    }

    #[test]
    fn validate_default_satisfies_optional() {
        let registry = InMemoryRegistry::new().with(dialect_with_typed_signature());
        // hidden has default false; not required; absence ok
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "ui"
    version: "1.0.0"
    bind:
      "|": ["widget"]
+++
foo: |widget(count: 5)
"#;
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        let call = &doc.decorators[0].decorators["|"][0];
        match &call.params[0] {
            ParamGroup::Named(m) => {
                assert_eq!(m["hidden"], Value::Bool(false), "default applied");
            }
            _ => panic!(),
        }
    }

    #[test]
    fn validate_strict_unknown_key_errors() {
        let registry = InMemoryRegistry::new().with(dialect_strict());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "ui"
    version: "1.0.0"
    bind:
      "|": ["widget"]
+++
foo: |widget(count: 1, surprise: 2)
"#;
        let r = decode_t1_with_registry(src, &registry);
        assert!(r.is_err(), "unknown 'surprise' key in strict mode must error");
    }

    #[test]
    fn validate_wildcard_skips_check() {
        // Default ParamSignature (mode=None=Wildcard) accepts anything.
        let mut d = dialect_with_typed_signature();
        d.families[0].params = ParamSignature::default();
        let registry = InMemoryRegistry::new().with(d);
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "ui"
    version: "1.0.0"
    bind:
      "|": ["widget"]
+++
foo: |widget(anything: "goes", literally: 9000)
"#;
        decode_t1_with_registry(src, &registry).expect("wildcard accepts everything");
    }

    #[test]
    fn validate_list_of_type() {
        let mut d = dialect_with_typed_signature();
        let mut typed = IndexMap::new();
        typed.insert("items".into(), TypedParam {
            ty: TypeSpec::ListOf(Box::new(TypeSpec::String)),
            required: false,
            default: None,
        });
        d.families[0].params = ParamSignature {
            mode: Some(ParamMode::WildcardWithTyped),
            typed,
            required: vec![],
            positional: vec![],
        };
        let registry = InMemoryRegistry::new().with(d);
        let src_ok = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "ui"
    version: "1.0.0"
    bind:
      "|": ["widget"]
+++
foo: |widget(items: ["a", "b"])
"#;
        decode_t1_with_registry(src_ok, &registry).unwrap();

        let src_bad = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "ui"
    version: "1.0.0"
    bind:
      "|": ["widget"]
+++
foo: |widget(items: ["a", 1])
"#;
        let r = decode_t1_with_registry(src_bad, &registry);
        assert!(r.is_err(), "list_of string with int element must error");
    }

    // ── Positional + variadic slot tests ────────────────────────────────────

    fn dialect_with_positional() -> DialectSpec {
        DialectSpec {
            name: "math".into(),
            version: "1.0.0".into(),
            version_strategy: VersionStrategy::default(),
            families: vec![
                FamilySpec {
                    name: "score".into(),
                    default_sigils: vec!["|".into()],
                    empty_default: Value::Table(Default::default()),
                    content_slot: None,
                    params: ParamSignature {
                        mode: Some(ParamMode::Positional),
                        typed: IndexMap::new(),
                        required: vec![],
                        positional: vec![
                            PositionalSlot {
                                name: "value".into(),
                                ty: TypeSpec::Integer,
                                required: true,
                                default: None,
                                variadic: false,
                            },
                            PositionalSlot {
                                name: "weight".into(),
                                ty: TypeSpec::Integer,
                                required: false,
                                default: Some(Value::Integer(1)),
                                variadic: false,
                            },
                        ],
                    },
                },
            ],
            structs: vec![],
        }
    }

    fn dialect_with_variadic() -> DialectSpec {
        DialectSpec {
            name: "kdl".into(),
            version: "1.0.0".into(),
            version_strategy: VersionStrategy::default(),
            families: vec![
                FamilySpec {
                    name: "node".into(),
                    default_sigils: vec!["|".into()],
                    empty_default: Value::Table(Default::default()),
                    content_slot: None,
                    params: ParamSignature {
                        mode: Some(ParamMode::Positional),
                        typed: IndexMap::new(),
                        required: vec![],
                        positional: vec![
                            PositionalSlot {
                                name: "label".into(),
                                ty: TypeSpec::String,
                                required: true,
                                default: None,
                                variadic: false,
                            },
                            PositionalSlot {
                                name: "args".into(),
                                ty: TypeSpec::Any,
                                required: false,
                                default: None,
                                variadic: true,
                            },
                        ],
                    },
                },
            ],
            structs: vec![],
        }
    }

    #[test]
    fn positional_correct_types_passes() {
        let registry = InMemoryRegistry::new().with(dialect_with_positional());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "math"
    version: "1.0.0"
    bind:
      "|": ["score"]
+++
foo: |score(95, 5)
"#;
        decode_t1_with_registry(src, &registry).unwrap();
    }

    #[test]
    fn positional_wrong_type_errors() {
        let registry = InMemoryRegistry::new().with(dialect_with_positional());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "math"
    version: "1.0.0"
    bind:
      "|": ["score"]
+++
foo: |score("notnum", 5)
"#;
        let r = decode_t1_with_registry(src, &registry);
        assert!(r.is_err());
    }

    #[test]
    fn positional_missing_required_errors() {
        let registry = InMemoryRegistry::new().with(dialect_with_positional());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "math"
    version: "1.0.0"
    bind:
      "|": ["score"]
+++
foo: |score()
"#;
        let r = decode_t1_with_registry(src, &registry);
        assert!(r.is_err());
    }

    #[test]
    fn positional_default_filled_in_ast() {
        let registry = InMemoryRegistry::new().with(dialect_with_positional());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "math"
    version: "1.0.0"
    bind:
      "|": ["score"]
+++
foo: |score(95)
"#;
        let doc = decode_t1_with_registry(src, &registry).unwrap();
        let call = &doc.decorators[0].decorators["|"][0];
        match &call.params[0] {
            ParamGroup::Positional(items) => {
                assert_eq!(items.len(), 2);
                assert_eq!(items[0], Value::Integer(95));
                assert_eq!(items[1], Value::Integer(1)); // default
            }
            _ => panic!(),
        }
    }

    #[test]
    fn variadic_collects_surplus() {
        let registry = InMemoryRegistry::new().with(dialect_with_variadic());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "kdl"
    version: "1.0.0"
    bind:
      "|": ["node"]
+++
foo: |node("x", 1, 2, 3)
"#;
        decode_t1_with_registry(src, &registry).unwrap();
    }

    #[test]
    fn variadic_only_no_required_works() {
        let mut d = dialect_with_variadic();
        d.families[0].params.positional[0].required = false;
        let registry = InMemoryRegistry::new().with(d);
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "kdl"
    version: "1.0.0"
    bind:
      "|": ["node"]
+++
foo: |node()
"#;
        decode_t1_with_registry(src, &registry).unwrap();
    }

    #[test]
    fn struct_validation_required_field_check() {
        let mut fields = IndexMap::new();
        fields.insert("street".into(), TypedParam {
            ty: TypeSpec::String,
            required: true,
            default: None,
        });
        fields.insert("city".into(), TypedParam {
            ty: TypeSpec::String,
            required: true,
            default: None,
        });
        let mut typed = IndexMap::new();
        typed.insert("addr".into(), TypedParam {
            ty: TypeSpec::Struct("Address".into()),
            required: true,
            default: None,
        });
        let d = DialectSpec {
            name: "ui".into(),
            version: "1.0.0".into(),
            version_strategy: VersionStrategy::default(),
            families: vec![
                FamilySpec {
                    name: "card".into(),
                    default_sigils: vec!["|".into()],
                    empty_default: Value::Table(Default::default()),
                    content_slot: None,
                    params: ParamSignature {
                        mode: Some(ParamMode::WildcardWithTyped),
                        typed,
                        required: vec![],
                        positional: vec![],
                    },
                },
            ],
            structs: vec![
                StructSpec { name: "Address".into(), fields },
            ],
        };
        let registry = InMemoryRegistry::new().with(d);

        let src_ok = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "ui"
    version: "1.0.0"
    bind:
      "|": ["card"]
+++
foo: |card(addr: { street: "1 Main", city: "SF" })
"#;
        decode_t1_with_registry(src_ok, &registry).unwrap();

        let src_bad = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "ui"
    version: "1.0.0"
    bind:
      "|": ["card"]
+++
foo: |card(addr: { street: "1 Main" })
"#;
        let r = decode_t1_with_registry(src_bad, &registry);
        assert!(r.is_err(), "missing required struct field must error");
    }

    // ── Flow-form decoration tests (TIER1.md §"Decoration sites" lines 610-616) ─

    #[test]
    fn flow_array_inner_decoration() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nfoo: [1, |tag() 2, 3]\n";
        let doc = decode_t1(src).unwrap();
        let body = match &doc.t0.body { Value::Table(t) => t, _ => panic!() };
        match &body["foo"] {
            Value::List(items) => {
                assert_eq!(items, &vec![Value::Integer(1), Value::Integer(2), Value::Integer(3)]);
            }
            _ => panic!(),
        }
        assert_eq!(doc.decorators.len(), 1);
        let entry = &doc.decorators[0];
        assert_eq!(entry.path, vec![BreadcrumbSegment::Key("foo".into()), BreadcrumbSegment::Index(1)]);
        assert_eq!(entry.decorators["|"][0].position, DecorationPosition::Inner);
    }

    #[test]
    fn flow_array_trailing_decoration() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nfoo: [1, 2 |tag(), 3]\n";
        let doc = decode_t1(src).unwrap();
        let body = match &doc.t0.body { Value::Table(t) => t, _ => panic!() };
        match &body["foo"] {
            Value::List(items) => {
                assert_eq!(items.len(), 3);
            }
            _ => panic!(),
        }
        let entry = &doc.decorators[0];
        assert_eq!(entry.path, vec![BreadcrumbSegment::Key("foo".into()), BreadcrumbSegment::Index(1)]);
        assert_eq!(entry.decorators["|"][0].position, DecorationPosition::Trailing);
    }

    #[test]
    fn flow_table_inner_decoration() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nfoo: {a: 1, b: |tag() 2}\n";
        let doc = decode_t1(src).unwrap();
        let body = match &doc.t0.body { Value::Table(t) => t, _ => panic!() };
        match &body["foo"] {
            Value::Table(t) => {
                assert_eq!(t["b"], Value::Integer(2));
            }
            _ => panic!(),
        }
        let entry = &doc.decorators[0];
        assert_eq!(entry.path, vec![BreadcrumbSegment::Key("foo".into()), BreadcrumbSegment::Key("b".into())]);
        assert_eq!(entry.decorators["|"][0].position, DecorationPosition::Inner);
    }

    #[test]
    fn flow_table_trailing_decoration() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nfoo: {a: 1 |tag(), b: 2}\n";
        let doc = decode_t1(src).unwrap();
        let entry = &doc.decorators[0];
        assert_eq!(entry.path, vec![BreadcrumbSegment::Key("foo".into()), BreadcrumbSegment::Key("a".into())]);
        assert_eq!(entry.decorators["|"][0].position, DecorationPosition::Trailing);
    }

    #[test]
    fn flow_array_multiple_decorations_per_element() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nfoo: [|tag() 1 |tag()]\n";
        let doc = decode_t1(src).unwrap();
        let entry = &doc.decorators[0];
        assert_eq!(entry.path, vec![BreadcrumbSegment::Key("foo".into()), BreadcrumbSegment::Index(0)]);
        let calls = &entry.decorators["|"];
        assert_eq!(calls.len(), 2);
        let positions: Vec<_> = calls.iter().map(|c| c.position).collect();
        assert!(positions.contains(&DecorationPosition::Inner));
        assert!(positions.contains(&DecorationPosition::Trailing));
    }

    // ── params_dec tests ─────────────────────────────────────────────────────

    #[test]
    fn params_dec_inner_on_named_value() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nfoo: |outer(label: |inner() \"v\")\n";
        let doc = decode_t1(src).unwrap();
        let call = &doc.decorators[0].decorators["|"][0];
        assert_eq!(call.fn_name, "outer");
        // params[0].label == "v"
        match &call.params[0] {
            ParamGroup::Named(m) => assert_eq!(m["label"], Value::String("v".into())),
            _ => panic!(),
        }
        // params_dec has the inner decoration on path [Index(0), Key("label")]
        assert_eq!(call.params_dec.len(), 1);
        let entry = &call.params_dec[0];
        assert_eq!(entry.path, vec![BreadcrumbSegment::Index(0), BreadcrumbSegment::Key("label".into())]);
        assert_eq!(entry.decorators["|"][0].fn_name, "inner");
        assert_eq!(entry.decorators["|"][0].position, DecorationPosition::Inner);
    }

    #[test]
    fn params_dec_trailing_on_named_value() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nfoo: |outer(label: \"v\" |inner())\n";
        let doc = decode_t1(src).unwrap();
        let call = &doc.decorators[0].decorators["|"][0];
        assert_eq!(call.params_dec.len(), 1);
        assert_eq!(call.params_dec[0].decorators["|"][0].position, DecorationPosition::Trailing);
    }

    #[test]
    fn params_dec_on_positional_element() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nfoo: |outer(1, |inner() 2, 3)\n";
        let doc = decode_t1(src).unwrap();
        let call = &doc.decorators[0].decorators["|"][0];
        match &call.params[0] {
            ParamGroup::Positional(items) => {
                assert_eq!(items, &vec![Value::Integer(1), Value::Integer(2), Value::Integer(3)]);
            }
            _ => panic!(),
        }
        assert_eq!(call.params_dec.len(), 1);
        assert_eq!(call.params_dec[0].path, vec![BreadcrumbSegment::Index(0), BreadcrumbSegment::Index(1)]);
    }

    #[test]
    fn params_dec_multi_group() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nfoo: |outer(a: |i1() 1)(b: |i2() 2)\n";
        let doc = decode_t1(src).unwrap();
        let call = &doc.decorators[0].decorators["|"][0];
        assert_eq!(call.params_dec.len(), 2);
        let p0 = &call.params_dec[0];
        let p1 = &call.params_dec[1];
        // group 0
        assert_eq!(p0.path, vec![BreadcrumbSegment::Index(0), BreadcrumbSegment::Key("a".into())]);
        assert_eq!(p0.decorators["|"][0].fn_name, "i1");
        // group 1
        assert_eq!(p1.path, vec![BreadcrumbSegment::Index(1), BreadcrumbSegment::Key("b".into())]);
        assert_eq!(p1.decorators["|"][0].fn_name, "i2");
    }

    #[test]
    fn no_params_dec_when_no_inner_decoration() {
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\nfoo: |outer(a: 1, b: 2)\n";
        let doc = decode_t1(src).unwrap();
        let call = &doc.decorators[0].decorators["|"][0];
        assert!(call.params_dec.is_empty());
    }

    // ── Semver parsing tests ─────────────────────────────────────────────────

    #[test]
    fn semver_parse_basic() {
        let v = parse_semver("1.2.3").unwrap();
        assert_eq!((v.major, v.minor, v.patch), (1, 2, 3));
        assert!(v.pre.is_empty());
    }

    #[test]
    fn semver_parse_pre_release() {
        let v = parse_semver("1.0.0-rc.1").unwrap();
        assert_eq!(v.pre.len(), 2);
        assert!(matches!(v.pre[0], SemverPreId::Alphanumeric(ref s) if s == "rc"));
        assert!(matches!(v.pre[1], SemverPreId::Numeric(1)));
    }

    #[test]
    fn semver_parse_build_metadata_ignored() {
        let v = parse_semver("1.0.0+linux").unwrap();
        assert_eq!((v.major, v.minor, v.patch), (1, 0, 0));
        assert!(v.pre.is_empty());
    }

    #[test]
    fn semver_parse_rejects_malformed() {
        assert!(parse_semver("abc").is_none());
        assert!(parse_semver("1").is_none());
        assert!(parse_semver("1.2").is_none());
        assert!(parse_semver("1.2.3.4").is_none());
    }

    #[test]
    fn semver_pre_release_ordering() {
        let alpha = parse_semver("1.0.0-alpha").unwrap();
        let beta = parse_semver("1.0.0-beta").unwrap();
        let rc = parse_semver("1.0.0-rc.1").unwrap();
        let release = parse_semver("1.0.0").unwrap();
        use std::cmp::Ordering::*;
        assert_eq!(cmp_semver(&alpha, &beta), Less);
        assert_eq!(cmp_semver(&beta, &rc), Less);
        assert_eq!(cmp_semver(&rc, &release), Less);
    }

    // ── version_satisfies tests ──────────────────────────────────────────────

    #[test]
    fn version_satisfies_exact() {
        let req = parse_semver("1.0.0").unwrap();
        let v100 = parse_semver("1.0.0").unwrap();
        let v101 = parse_semver("1.0.1").unwrap();
        assert!(version_satisfies(&v100, &req, VersionStrategy::Exact));
        assert!(!version_satisfies(&v101, &req, VersionStrategy::Exact));
    }

    #[test]
    fn version_satisfies_caret_same_major_increasing() {
        let req = parse_semver("1.2.0").unwrap();
        assert!(version_satisfies(&parse_semver("1.2.0").unwrap(), &req, VersionStrategy::Caret));
        assert!(version_satisfies(&parse_semver("1.5.0").unwrap(), &req, VersionStrategy::Caret));
        assert!(version_satisfies(&parse_semver("1.99.99").unwrap(), &req, VersionStrategy::Caret));
        assert!(!version_satisfies(&parse_semver("2.0.0").unwrap(), &req, VersionStrategy::Caret));
        assert!(!version_satisfies(&parse_semver("1.1.0").unwrap(), &req, VersionStrategy::Caret));
    }

    #[test]
    fn version_satisfies_caret_zero_x_acts_tilde() {
        let req = parse_semver("0.5.0").unwrap();
        assert!(version_satisfies(&parse_semver("0.5.0").unwrap(), &req, VersionStrategy::Caret));
        assert!(version_satisfies(&parse_semver("0.5.5").unwrap(), &req, VersionStrategy::Caret));
        // 0.6.0 is a breaking change in pre-1.0
        assert!(!version_satisfies(&parse_semver("0.6.0").unwrap(), &req, VersionStrategy::Caret));
    }

    #[test]
    fn version_satisfies_tilde() {
        let req = parse_semver("1.2.0").unwrap();
        assert!(version_satisfies(&parse_semver("1.2.0").unwrap(), &req, VersionStrategy::Tilde));
        assert!(version_satisfies(&parse_semver("1.2.5").unwrap(), &req, VersionStrategy::Tilde));
        assert!(!version_satisfies(&parse_semver("1.3.0").unwrap(), &req, VersionStrategy::Tilde));
        assert!(!version_satisfies(&parse_semver("2.0.0").unwrap(), &req, VersionStrategy::Tilde));
    }

    #[test]
    fn version_satisfies_gte() {
        let req = parse_semver("1.0.0").unwrap();
        assert!(version_satisfies(&parse_semver("1.0.0").unwrap(), &req, VersionStrategy::Gte));
        assert!(version_satisfies(&parse_semver("99.0.0").unwrap(), &req, VersionStrategy::Gte));
        assert!(!version_satisfies(&parse_semver("0.99.99").unwrap(), &req, VersionStrategy::Gte));
    }

    #[test]
    fn version_satisfies_any() {
        let req = parse_semver("1.0.0").unwrap();
        assert!(version_satisfies(&parse_semver("0.0.1").unwrap(), &req, VersionStrategy::Any));
        assert!(version_satisfies(&parse_semver("99.99.99").unwrap(), &req, VersionStrategy::Any));
    }

    // ── Registry caret strategy integration test ─────────────────────────────

    #[test]
    fn registry_caret_picks_highest_compatible() {
        let mut r = InMemoryRegistry::new();
        let make = |v: &str| DialectSpec {
            name: "x".into(),
            version: v.into(),
            version_strategy: VersionStrategy::Caret,
            families: vec![],
            structs: vec![],
        };
        r = r.with(make("1.0.0")).with(make("1.5.0")).with(make("2.0.0"));
        let resolved = r.resolve("x", "1.0.0").unwrap();
        assert_eq!(resolved.version, "1.5.0");
    }

    // ── extract_imports range-specifier rejection ─────────────────────────────

    #[test]
    fn extract_imports_rejects_range_specifier() {
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "x"
    version: "^1.0.0"
+++
"#;
        let r = decode_t1(src);
        assert!(r.is_err(), "range specifier in file must error");
    }

    // ── A. Friendly type names in validation errors ───────────────────────────

    /// Helper: build a dialect with strict param mode requiring `class: string`.
    fn dialect_strict_class() -> DialectSpec {
        use crate::Value;
        DialectSpec {
            name: "x".into(),
            version: "1.0.0".into(),
            version_strategy: VersionStrategy::default(),
            families: vec![FamilySpec {
                name: "tag".into(),
                default_sigils: vec!["|".into()],
                empty_default: Value::Table(Default::default()),
                content_slot: None,
                params: ParamSignature {
                    mode: Some(ParamMode::Strict),
                    typed: {
                        let mut m = IndexMap::new();
                        m.insert(
                            "class".into(),
                            TypedParam {
                                ty: TypeSpec::String,
                                required: false,
                                default: None,
                            },
                        );
                        m
                    },
                    required: vec![],
                    positional: vec![],
                },
            }],
            structs: vec![],
        }
    }

    #[test]
    fn type_mismatch_error_shows_friendly_name_not_discriminant() {
        let registry = InMemoryRegistry::new().with(dialect_strict_class());
        // Pass an integer where string is expected.
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\n|tag(class: 42)\nkey: 1\n";
        let r = decode_t1_with_registry(src, &registry);
        assert!(r.is_err(), "type mismatch must error");
        let msg = r.unwrap_err().message;
        // Must show friendly name "integer", not internal "Discriminant(N)".
        assert!(
            msg.contains("integer"),
            "error must mention 'integer', got: {msg}"
        );
        assert!(
            !msg.contains("Discriminant"),
            "error must NOT contain 'Discriminant', got: {msg}"
        );
    }

    #[test]
    fn type_mismatch_error_shows_boolean_label() {
        let registry = InMemoryRegistry::new().with(dialect_strict_class());
        let src = "+++\n_dms_tier: 1\n_dms_imports:\n  + dialect: \"x\"\n    version: \"1.0.0\"\n    bind:\n      \"|\": [\"tag\"]\n+++\n|tag(class: true)\nkey: 1\n";
        let r = decode_t1_with_registry(src, &registry);
        assert!(r.is_err());
        let msg = r.unwrap_err().message;
        assert!(msg.contains("boolean"), "expected 'boolean' in error, got: {msg}");
    }

    // ── B. mutate_t1 API ─────────────────────────────────────────────────────

    /// Build a DocumentT1 with a list body and some decorator entries for mutation tests.
    fn make_list_doc_with_decorators() -> DocumentT1 {
        use crate::Value;
        // Body: [10, 20, 30]
        let mut doc: DocumentT1 = crate::Document {
            meta: Default::default(),
            body: Value::List(vec![
                Value::Integer(10),
                Value::Integer(20),
                Value::Integer(30),
            ]),
            comments: vec![],
            original_forms: Default::default(),
        }
        .into();
        // Decorator at index 0 and index 2.
        let make_entry = |idx: usize| DecoratorEntry {
            path: vec![BreadcrumbSegment::Index(idx)],
            decorators: {
                let mut m = IndexMap::new();
                m.insert("|".into(), vec![DecoratorCall {
                    sigil: "|".into(),
                    family: "tag".into(),
                    fn_name: "x".into(),
                    ns: None,
                    params: vec![],
                    params_dec: vec![],
                    position: DecorationPosition::Inner,
                }]);
                m
            },
            comments: vec![],
        };
        doc.decorators.push(make_entry(0));
        doc.decorators.push(make_entry(2));
        doc
    }

    #[test]
    fn insert_at_list_shifts_subsequent_decorator_paths() {
        let mut doc = make_list_doc_with_decorators();
        // Insert at index 1 → items 1 and 2 shift up.
        // Before: decorators at [0] and [2].
        // After insert at 1: decorators should be at [0] and [3].
        doc.insert_at(&[BreadcrumbSegment::Index(1)], crate::Value::Integer(99))
            .expect("insert must succeed");
        // Body length is now 4.
        let body = match &doc.t0.body {
            crate::Value::List(l) => l,
            _ => panic!("expected list"),
        };
        assert_eq!(body.len(), 4);
        assert_eq!(body[1], crate::Value::Integer(99));
        // Decorator at index 0 unchanged; decorator at (old) index 2 now at 3.
        let paths: Vec<&Vec<BreadcrumbSegment>> =
            doc.decorators.iter().map(|e| &e.path).collect();
        assert_eq!(paths[0], &[BreadcrumbSegment::Index(0)]);
        assert_eq!(paths[1], &[BreadcrumbSegment::Index(3)]);
    }

    #[test]
    fn remove_at_list_drops_entry_and_shifts_higher_indices() {
        let mut doc = make_list_doc_with_decorators();
        // Remove index 0 → decorator at [0] is dropped; decorator at [2] → [1].
        let removed = doc.remove_at(&[BreadcrumbSegment::Index(0)])
            .expect("remove must succeed");
        assert_eq!(removed, crate::Value::Integer(10));
        // Body is now [20, 30].
        let body = match &doc.t0.body {
            crate::Value::List(l) => l,
            _ => panic!("expected list"),
        };
        assert_eq!(body.len(), 2);
        // One decorator left, now at [1].
        assert_eq!(doc.decorators.len(), 1);
        assert_eq!(doc.decorators[0].path, [BreadcrumbSegment::Index(1)]);
    }

    #[test]
    fn remove_at_table_drops_decorators_at_key() {
        use crate::Value;
        let mut map = indexmap::IndexMap::new();
        map.insert("a".to_string(), Value::Integer(1));
        map.insert("b".to_string(), Value::Integer(2));
        let mut doc: DocumentT1 = crate::Document {
            meta: Default::default(),
            body: Value::Table(map),
            comments: vec![],
            original_forms: Default::default(),
        }
        .into();
        // Add decorator at key "a" and key "b".
        let make_key_entry = |key: &str| DecoratorEntry {
            path: vec![BreadcrumbSegment::Key(key.into())],
            decorators: {
                let mut m = IndexMap::new();
                m.insert("|".into(), vec![DecoratorCall {
                    sigil: "|".into(),
                    family: "tag".into(),
                    fn_name: "x".into(),
                    ns: None,
                    params: vec![],
                    params_dec: vec![],
                    position: DecorationPosition::Inner,
                }]);
                m
            },
            comments: vec![],
        };
        doc.decorators.push(make_key_entry("a"));
        doc.decorators.push(make_key_entry("b"));

        let removed = doc.remove_at(&[BreadcrumbSegment::Key("a".into())])
            .expect("remove must succeed");
        assert_eq!(removed, Value::Integer(1));
        // Only decorator for "b" remains.
        assert_eq!(doc.decorators.len(), 1);
        assert_eq!(doc.decorators[0].path, [BreadcrumbSegment::Key("b".into())]);
    }

    #[test]
    fn replace_at_preserves_decorators() {
        let mut doc = make_list_doc_with_decorators();
        // Replace index 0 (has a decorator).
        let old = doc
            .replace_at(
                &[BreadcrumbSegment::Index(0)],
                crate::Value::Integer(999),
                false,
            )
            .expect("replace must succeed");
        assert_eq!(old, crate::Value::Integer(10));
        // Both decorators preserved.
        assert_eq!(doc.decorators.len(), 2);
        // Value changed.
        let body = match &doc.t0.body {
            crate::Value::List(l) => l,
            _ => panic!(),
        };
        assert_eq!(body[0], crate::Value::Integer(999));
    }

    #[test]
    fn replace_at_with_drop_removes_descendant_decorators() {
        use crate::Value;
        // Build a doc with a nested list inside a table.
        let mut inner = indexmap::IndexMap::new();
        inner.insert("items".to_string(), Value::List(vec![Value::Integer(1), Value::Integer(2)]));
        let mut doc: DocumentT1 = crate::Document {
            meta: Default::default(),
            body: Value::Table(inner),
            comments: vec![],
            original_forms: Default::default(),
        }
        .into();
        // Decorator at items[0].
        doc.decorators.push(DecoratorEntry {
            path: vec![
                BreadcrumbSegment::Key("items".into()),
                BreadcrumbSegment::Index(0),
            ],
            decorators: {
                let mut m = IndexMap::new();
                m.insert("|".into(), vec![DecoratorCall {
                    sigil: "|".into(),
                    family: "tag".into(),
                    fn_name: "x".into(),
                    ns: None,
                    params: vec![],
                    params_dec: vec![],
                    position: DecorationPosition::Inner,
                }]);
                m
            },
            comments: vec![],
        });
        // Decorator at the "items" key itself.
        doc.decorators.push(DecoratorEntry {
            path: vec![BreadcrumbSegment::Key("items".into())],
            decorators: {
                let mut m = IndexMap::new();
                m.insert("|".into(), vec![DecoratorCall {
                    sigil: "|".into(),
                    family: "tag".into(),
                    fn_name: "x".into(),
                    ns: None,
                    params: vec![],
                    params_dec: vec![],
                    position: DecorationPosition::Inner,
                }]);
                m
            },
            comments: vec![],
        });

        // Replace "items" with a scalar, dropping descendants.
        doc.replace_at(
            &[BreadcrumbSegment::Key("items".into())],
            Value::Integer(42),
            true,
        )
        .expect("replace must succeed");

        // The decorator at items[0] (descendant) must be gone.
        // The decorator at "items" itself is NOT a descendant (same path length, not longer).
        assert_eq!(doc.decorators.len(), 1);
        assert_eq!(
            doc.decorators[0].path,
            [BreadcrumbSegment::Key("items".into())]
        );
    }

    #[test]
    fn insert_at_error_on_out_of_bounds() {
        let mut doc = make_list_doc_with_decorators();
        // List has 3 elements; index 5 is out of bounds.
        let r = doc.insert_at(&[BreadcrumbSegment::Index(5)], crate::Value::Integer(0));
        assert!(r.is_err(), "out-of-bounds insert must error");
    }

    #[test]
    fn remove_at_error_on_missing_key() {
        use crate::Value;
        let mut map = indexmap::IndexMap::new();
        map.insert("a".to_string(), Value::Integer(1));
        let mut doc: DocumentT1 = crate::Document {
            meta: Default::default(),
            body: Value::Table(map),
            comments: vec![],
            original_forms: Default::default(),
        }
        .into();
        let r = doc.remove_at(&[BreadcrumbSegment::Key("nonexistent".into())]);
        assert!(r.is_err(), "removing missing key must error");
    }

    // ── C. dms_html_dialect() ────────────────────────────────────────────────

    #[test]
    fn dms_html_dialect_has_tag_and_entity_families() {
        let d = dms_html_dialect();
        assert_eq!(d.name, "html");
        assert_eq!(d.version, "1.0.0");
        let tag = d.family("tag").expect("tag family must exist");
        assert_eq!(tag.default_sigils, &["|".to_string()]);
        assert_eq!(tag.content_slot.as_deref(), Some("children"));
        let entity = d.family("entity").expect("entity family must exist");
        assert_eq!(entity.default_sigils, &["&".to_string()]);
        assert!(entity.content_slot.is_none());
    }

    #[test]
    fn dms_html_dialect_decode_and_hoist() {
        let registry = InMemoryRegistry::new().with(dms_html_dialect());
        let src = r#"+++
_dms_tier: 1
_dms_imports:
  + dialect: "html"
    version: "1.0.0"
    bind:
      "|": ["tag"]
      "&": ["entity"]
+++
|p(class: "lede", children: ["hello world"])
body: {}
"#;
        let doc = decode_t1_with_registry(src, &registry)
            .expect("decode with dms_html_dialect must succeed");
        // Children hoisted into body.
        let body = match &doc.t0.body {
            crate::Value::Table(t) => t,
            _ => panic!("expected table body"),
        };
        let body_val = body.get("body").expect("body key present");
        match body_val {
            crate::Value::List(items) => {
                assert_eq!(items.len(), 1);
                assert_eq!(items[0], crate::Value::String("hello world".into()));
            }
            other => panic!("expected list after hoist, got {other:?}"),
        }
        // class param remains; children removed from params.
        let call = &doc.decorators[0].decorators["|"][0];
        assert_eq!(call.fn_name, "p");
        match &call.params[0] {
            ParamGroup::Named(m) => {
                assert!(!m.contains_key("children"), "children must be hoisted out");
                assert!(m.contains_key("class"), "class must remain");
            }
            _ => panic!("expected Named param group"),
        }
    }

    // ── lex_sigil_atom unit tests ─────────────────────────────────────────────

    #[test]
    fn lex_sigil_atom_ascii() {
        assert_eq!(lex_sigil_atom("|tag", 0), Some(1));
        assert_eq!(lex_sigil_atom("@x", 0), Some(1));
        assert_eq!(lex_sigil_atom("a", 0), None);
    }

    #[test]
    fn lex_sigil_atom_single_emoji() {
        assert_eq!(lex_sigil_atom("🚀tag", 0), Some(4)); // 🚀 is U+1F680, 4 bytes UTF-8
    }

    #[test]
    fn lex_sigil_atom_flag_pair() {
        let s = "🇺🇸tag";
        let n = lex_sigil_atom(s, 0).unwrap();
        assert_eq!(n, 8); // two regional indicators, 4 bytes each
        assert_eq!(&s[..n], "🇺🇸");
    }

    #[test]
    fn lex_sigil_atom_skin_tone() {
        let s = "👍🏽tag";
        let n = lex_sigil_atom(s, 0).unwrap();
        // 👍 (4 bytes) + 🏽 (4 bytes) = 8 bytes for the cluster
        assert_eq!(&s[..n], "👍🏽");
    }

    #[test]
    fn lex_sigil_atom_skin_tone_distinct_from_base() {
        // The two clusters are distinct sigil atoms by spec
        let plain = "👍tag";
        let toned = "👍🏽tag";
        let n_plain = lex_sigil_atom(plain, 0).unwrap();
        let n_toned = lex_sigil_atom(toned, 0).unwrap();
        assert_ne!(n_plain, n_toned);
    }

    #[test]
    fn lex_sigil_atom_keycap() {
        let s = "1️⃣tag"; // 1 + FE0F + 20E3
        // First codepoint is digit, not a sigil → None
        assert_eq!(lex_sigil_atom(s, 0), None);
    }
}
