"""
moml2.py — MOML 2.0 (MKR's Ordinary Markup Language, V2)
=========================================================

Clean-room implementation against the MOML 2.0 Language Specification.
Does not share code, structure, or test patterns with any MOML V1
implementation (moml.py, moml.php, moml.js, MOML.ahk) — those are frozen
and out of scope. This exists because V1's type inference produced a
class of silent-corruption bugs (a leading zero lost from a hex colour,
a Windows path corrupted by escape processing, a "#" vanishing from a
value) that V2's design removes structurally rather than working around.

THE SEVEN RULES
---------------
1. Everything is a string. No Boolean/int/float/null inference, ever.
   Type conversion belongs to the gatekeeper, not this parser.
2. Scalars are bare unless quoting is required. Quotes protect syntax
   (comma, quote character, leading/trailing whitespace, embedded
   newline/tab) — they never declare a type. `name = Mike` and
   `name = "Mike"` are the same value.
3. Backslash is literal outside quotes. `C:\\Users\\Karim` needs no
   escaping. Inside quotes, backslash is the escape character and
   supports exactly four sequences: \\\\  \\"  \\n  \\t.
4. "#" is a comment only when it is the first non-whitespace character
   of a line. Anywhere else it is ordinary data — `hotkey = #h` and
   `color = #085041` need no quoting or escaping.
5. "{ }" defines a block. A block holds either key/value pairs and
   named child blocks, or bare items — never both in the same block.
6. A comma outside quotes separates inline-list elements. "*" has no
   structural meaning inside an inline list (it is just a character in
   the string); "*" is structural only when it prefixes an entry inside
   a "{ }" block, marking that entry as active in its parent.
7. An assignment with nothing after "=" is the empty string. A list
   element that must be empty is written as an explicit "" — a bare
   trailing or embedded comma with nothing between is a parse error in
   this implementation (see NOTE ON GAPS below), not silent sugar for
   an empty element, because bare "a," reads as an incomplete line and
   the reader can't tell you meant that.

TYPES RETURNED
--------------
    scalar          -> str
    "key ="         -> "" (empty string, never None/null)
    inline list      -> plain Python list of str  (no .active — Rule 6)
    bare-item block  -> MOMLList (list subclass, carries .active)
    pairs/dict block -> MOMLDict (dict subclass, carries .active)

NOTE ON GAPS — decisions made where the spec is silent
-------------------------------------------------------
The spec document (MOML 2.0 Language Specification) defines the seven
rules precisely but does not specify parser behaviour for a few
malformed-input cases. Each decision below was flagged during design
and is documented here rather than silently assumed:

  * Unterminated quoted string           -> MOMLError, loud failure.
  * A token that is partially quoted,
    e.g. `abc"def"` or `"abc"def`         -> MOMLError. Only two shapes
    are accepted: no quote characters at all, or the token entirely
    spanned by one matched pair of quotes.
  * Missing closing "}"                  -> MOMLError (matches V1
    precedent and is the only sane behaviour).
  * A bare (unquoted) empty list element,
    e.g. `a,,b` or a trailing `a,`        -> MOMLError. The spec's own
    example writes the empty element explicitly as `a, ""`; this
    implementation requires that explicit form rather than silently
    treating a bare comma as sugar for it, because `a,` reads as an
    unfinished line, not a deliberate one-element list.
  * Duplicate key in one block            -> last write wins, no error.
    Not in scope per Rule 5's own examples; matches ordinary dict
    semantics and is not a structural conflict the way duplicate "*"
    is.
  * Duplicate "*" in one block            -> MOMLError. Two entries
    both claiming to be "the" active one has no sensible resolution.
  * A bare-item block element whose literal value would itself read
    back as structure — starts with "#" (a comment), starts with "*"
    (an active marker), contains "=" (would be mistaken for a
    key=value pair on reread — see below), is exactly "}" (a close),
    or ends in an unquoted "{" (a nested block open, e.g. the string
    "nested {")                          -> quoted automatically on
    write. This was found empirically, not anticipated in the spec: a
    first pass at this writer (and, independently, a second MOML 2.0
    implementation built against the same spec) both quoted the first
    three shapes correctly but missed the "{"-suffix case, because both
    checked literal prefixes rather than asking "would writing this
    bare produce a line the parser's own block-open pattern matches."
    A third pass — this one — fixed that, and a further external review
    of THIS implementation then found the "=" case missed here too: a
    Python value ["x = y"] inside a bare-item block wrote as the bare
    line `x = y`, which reread as {"x": "y"} — a pair — instead of the
    one-element list ["x = y"]. Each of these is the same lesson twice:
    a Python list ["#tag"] silently vanishing (read as a comment),
    ["*star"] silently gaining a fake active marker, and ["x = y"]
    silently becoming a dict entry are all the same class of
    corruption this whole design exists to prevent — they just moved
    from the parser's type inference to the writer's quoting logic.
    Fixed, again, by checking against the actual grammar (does an
    unquoted "=" let `_split_pair` claim this line) rather than
    enumerating shapes, which is what keeps closing each variant by
    construction instead of by exhaustively guessing the next one.

    The "=" case additionally needed a matching parser fix, not just a
    writer fix: `_split_pair` used to find the first "=" with a plain
    index search, so even a correctly QUOTED value containing "="
    would still break — `"x = y"` as a whole bare-item line has an "="
    inside the quotes, and a naive search finds it there, splitting a
    valid quoted string into a garbage key fragment and an unterminated
    value. `_split_pair` now finds the first UNQUOTED "=" (the same
    quote-tracking scan used elsewhere in this module), so quoting the
    value on write and correctly declining to split it on read are a
    matched pair — either alone is insufficient.

  * Two identical bare-item values with only ONE of them marked active
    — a legal file (`a` then `*a`, exactly one literal "*") — used to
    round-trip into an ILLEGAL one. .active stores the active VALUE, not
    a position, so the writer's `item == block.active` comparison
    matched BOTH occurrences of the value "a" and starred both, and the
    parser's own duplicate-active check then correctly rejected its own
    output on the next read: a valid parse became unreadable purely by
    being written back out. Found by an external reviewer running large
    randomized round-trip batches rather than by hand-picked examples —
    exactly the kind of case that's easy to miss by construction and
    only shows up under volume. Fixed by starring only the FIRST
    matching occurrence: since the data model genuinely cannot
    distinguish "the first a" from "the second a" (both are simply the
    string "a"), the first-match rule is the only deterministic choice
    available, and it's the one that makes write-then-read stable again.

  * A non-string scalar reaching the writer — a gatekeeper bug handing
    dumps() {"timeout": 45} instead of {"timeout": "45"} — used to fail
    as an ordinary Python TypeError from deep inside a string-membership
    check ('"' in s on an int), with no indication the problem was a
    Rule 1 violation at all. Rule 1 says type conversion belongs to the
    gatekeeper; that was previously true only by convention, not
    enforced. `_require_str` now checks at both leaf serializer
    functions and raises a MOMLError naming Rule 1 explicitly, so a
    gatekeeper bug is caught with a message that says what actually
    went wrong instead of an unrelated stack trace.
  * A genuine one-element inline list, e.g. app value ["#h"]
                                          -> written padded, as
    `#h, ""`, so it survives as a list (not a collapsed scalar) with no
    cooperation required from the reading gatekeeper. Filling this in
    was necessary, not optional: leaving a one-element list to write as
    a bare scalar means it reads back as a plain string on the very
    next load, silently losing its list-ness — the gatekeeper CAN
    recover from that by re-wrapping a lone string, but only if every
    caller remembers to. Padding here means the round trip is correct
    at the moml2 layer alone.
  * A genuinely empty list ([]) has no bare-syntax representation
    distinct from an empty scalar, since Rule 7 makes `key =`
    unconditionally the empty string. Written as `key =`; comes back
    as "" on reload. A gatekeeper that needs empty-list and
    empty-string distinguished has to track that itself — rare enough
    in practice not to warrant inventing new syntax for it.

PUBLIC API
----------
    config = loads(text)            # str  -> MOMLDict (requires the
                                     #         "# MOML 2.0" header line)
    config = load(path)             # file -> MOMLDict (path must end .m2)
    text   = dumps(config)          # MOMLDict -> str  (header written)
    dump(config, path)              # MOMLDict -> file (path must end .m2)

    value = get(config, "a", "b", default=None)
    key, value = get_active(block)  # active entry of a MOMLDict/MOMLList
"""

from __future__ import annotations
import re
from pathlib import Path
from typing import Any


# Every MOML 2.0 file starts with this exact line. Enforced by loads()
# (content-level — a string handed to loads() with no header is rejected
# the same as a file would be) and by the .m2 extension check in load()/
# dump() (path-level — a differently-named file is rejected before its
# content is even read). Both checks exist because V1 and V2 files must
# never be silently interchangeable: a V1 parser handed V2 syntax, or a
# V2 parser handed a V1 file, doesn't error — it silently misparses,
# which is the exact failure class this whole design exists to prevent.
HEADER = "# MOML 2.0"


# ── Exceptions ────────────────────────────────────────────────────────────────

class MOMLError(Exception):
    """Any parse or structural error. Carries a human-readable message
    including a line number where one is known."""
    pass


# ── Containers ────────────────────────────────────────────────────────────────

class MOMLDict(dict):
    """
    A pairs/named-block container (dict subclass). `.active` holds the
    key name marked with a leading "*" in the source, or None.

    Using a dedicated attribute rather than a reserved '*' dict key (as
    MOML V1's moml.py does) avoids any collision with a real key that
    happens to be named '*', however unlikely.
    """
    def __init__(self, *a, **kw):
        super().__init__(*a, **kw)
        self.active: str | None = None

    def __repr__(self):
        return f"MOMLDict({dict.__repr__(self)}, active={self.active!r})"


class MOMLList(list):
    """
    A bare-item block container (list subclass, one physical line per
    item). `.active` holds the parsed value of the item marked with a
    leading "*", or None.

    Distinct from a plain Python `list`, which represents an INLINE
    comma list (`key = a, b, c`). Rule 6 states "*" has no structural
    meaning inside an inline list — modelling inline lists as plain
    `list` with no `.active` attribute makes that rule impossible to
    violate by construction rather than a convention someone has to
    remember.
    """
    def __init__(self, *a, **kw):
        super().__init__(*a, **kw)
        self.active: str | None = None

    def __repr__(self):
        return f"MOMLList({list.__repr__(self)}, active={self.active!r})"


# ── Line classification patterns ───────────────────────────────────────────────

# [*]key {   — block open. Must END the (stripped) line; nothing after "{".
_BLOCK_OPEN = re.compile(r"^(\*)?(\S+)\s*\{$")
# }           — block close, alone on its line.
_CLOSE = re.compile(r"^\}$")


# ── Quote-aware scanning ────────────────────────────────────────────────────────

def _unescape(inner: str) -> str:
    """
    Decode \\\\ \\" \\n \\t inside the interior of a quoted string (the
    text between the opening and closing quote, not including them).

    Single-pass character scan, not sequential global replace. This is
    a deliberate choice: MOML V1's AutoHotkey port had its writer emit
    one backslash as two, while its reader matched a run of FOUR before
    collapsing — an asymmetry that sequential find-and-replace made easy
    to get subtly wrong and a 62-test suite still missed for a year. A
    single left-to-right scan that consumes exactly two characters per
    escape cannot exhibit that class of bug: there is no second pass to
    disagree with the first.
    """
    out = []
    i = 0
    n = len(inner)
    while i < n:
        c = inner[i]
        if c == "\\":
            if i + 1 >= n:
                raise MOMLError(f"Trailing backslash in quoted string: {inner!r}")
            nc = inner[i + 1]
            if nc == "\\":
                out.append("\\")
            elif nc == '"':
                out.append('"')
            elif nc == "n":
                out.append("\n")
            elif nc == "t":
                out.append("\t")
            else:
                raise MOMLError(
                    f'Unknown escape sequence "\\{nc}" in quoted string: {inner!r} '
                    f'(only \\\\, \\", \\n, \\t are defined)'
                )
            i += 2
            continue
        out.append(c)
        i += 1
    return "".join(out)


def _fully_quoted(s: str) -> bool:
    """
    True if `s` is exactly one quoted string spanning its entire length
    — opens with an unescaped '"' at position 0, closes with an
    unescaped '"' at the last position, nothing before or after.

    Raises MOMLError if a quote is opened but never closed. Returns
    False (does not raise) if a quote closes before the end of `s`,
    leaving trailing characters — that is a distinct, equally invalid
    shape (partial quoting) that the caller reports with its own,
    clearer message.
    """
    if len(s) < 2 or s[0] != '"':
        return False
    i = 1
    n = len(s)
    while i < n:
        c = s[i]
        if c == "\\":
            if i + 1 >= n:
                raise MOMLError(f"Unterminated escape in quoted string: {s!r}")
            i += 2
            continue
        if c == '"':
            return i == n - 1
        i += 1
    raise MOMLError(f"Unterminated quoted string: {s!r}")


def _parse_scalar_text(s: str) -> str:
    """
    Parse one already-isolated piece of text (a single list element, a
    bare-item line, or the sole token of a scalar assignment) into its
    string value.

    No comma-splitting here — that is `_scan_value`'s job, and it only
    applies to the right-hand side of an assignment (Rule 6 is scoped
    to `key = value`, not to bare-item lines).
    """
    if '"' not in s:
        return s  # bare: literal, unescaped, backslash stays as typed (Rule 3)
    if _fully_quoted(s):
        return _unescape(s[1:-1])
    raise MOMLError(
        f"Partial or malformed quoting is not supported — a value must be "
        f"either fully bare or fully wrapped in one pair of quotes: {s!r}"
    )


def _scan_value(s: str) -> list[str]:
    """
    Split the right-hand side of an assignment on top-level commas —
    commas that are not inside a quoted region. Returns raw (untrimmed,
    still-quoted-if-quoted) substrings; the caller trims and parses each
    one with `_parse_scalar_text`.

    Tracks quote state character by character so a comma inside quotes
    (`"Smith, John"`) is never mistaken for a list separator (Rule 6:
    "A comma inside quotes is literal text").
    """
    tokens = []
    start = 0
    in_quotes = False
    i = 0
    n = len(s)
    while i < n:
        c = s[i]
        if in_quotes:
            if c == "\\":
                if i + 1 >= n:
                    raise MOMLError(f"Unterminated escape in value: {s!r}")
                i += 2
                continue
            if c == '"':
                in_quotes = False
            i += 1
            continue
        else:
            if c == '"':
                in_quotes = True
                i += 1
                continue
            if c == ",":
                tokens.append(s[start:i])
                start = i + 1
                i += 1
                continue
            i += 1
            continue
    if in_quotes:
        raise MOMLError(f"Unterminated quoted string in value: {s!r}")
    tokens.append(s[start:])
    return tokens


def _parse_assignment_value(raw_value: str, line_no: int):
    """
    Parse everything after the first "=" in a `key = value` line.

    One token, empty after trimming -> "" (Rule 7).
    One token, non-empty          -> scalar str.
    Two or more tokens            -> plain list[str] (an inline comma
                                      list — Rule 6, no .active).
    """
    tokens = _scan_value(raw_value)
    if len(tokens) == 1:
        t = tokens[0].strip()
        if t == "":
            return ""
        return _parse_scalar_text(t)

    values = []
    for raw_tok in tokens:
        t = raw_tok.strip()
        if t == "":
            raise MOMLError(
                f"Empty list element must be written as an explicit \"\" "
                f"(line {line_no}): {raw_value!r}"
            )
        values.append(_parse_scalar_text(t))
    return values


def _find_unquoted_equals(line: str) -> int | None:
    """
    Return the index of the first "=" that is NOT inside a quoted
    region, or None if there is none.

    Needed because a bare-item block value can itself be a quoted
    string containing "=" — the whole physical line `"x = y"` — and a
    naive `str.find("=")` finds the "=" whether or not it's inside
    quotes, splitting the line at the wrong point (breaking a quoted
    string in half rather than recognising it as one bare item). This
    scan tracks quote state the same way `_scan_value` does and skips
    anything inside a quoted region.
    """
    in_quotes = False
    i = 0
    n = len(line)
    while i < n:
        c = line[i]
        if in_quotes:
            if c == "\\":
                if i + 1 >= n:
                    raise MOMLError(f"Unterminated escape in line: {line!r}")
                i += 2
                continue
            if c == '"':
                in_quotes = False
            i += 1
            continue
        if c == '"':
            in_quotes = True
            i += 1
            continue
        if c == "=":
            return i
        i += 1
    return None


def _split_pair(line: str):
    """
    Return (star, key, raw_value) if `line` is a `[*]key = value` pair,
    else None.

    The key/value boundary is the first UNQUOTED "=" in the line — the
    key is always a bare, unquoted single token, so it cannot itself
    contain "=" or a quote character. Using the quote-aware scan rather
    than a plain index search is what makes `equation = x = y + z` and
    `url = https://example.com/?a=1&b=2` work without special-casing
    (the remainder is free to contain "=" naturally, spec section 3),
    while correctly refusing to split a bare-item line that is entirely
    a quoted string containing its own "=", e.g. `"x = y"` — a value
    quoted for exactly this reason by `_bare_item_needs_quoting` below.
    Quoting the value and fixing this scan are a matched pair: quoting
    alone still breaks under the old naive find(), which locates the
    "=" INSIDE the quotes and splits a valid quoted string into a
    garbage key fragment and an unterminated value.
    """
    idx = _find_unquoted_equals(line)
    if idx is None:
        return None
    key_part = line[:idx].strip()
    star = key_part.startswith("*")
    if star:
        key_part = key_part[1:].strip()
    if not key_part or re.search(r"\s", key_part):
        raise MOMLError(f"Invalid key before '=': {line[:idx]!r}")
    raw_value = line[idx + 1:]
    return star, key_part, raw_value


# ── Block parser ────────────────────────────────────────────────────────────────

def _parse_block(lines: list[str], pos: int, top_level: bool):
    """
    Parse statements starting at `lines[pos]`. If `top_level`, runs to
    end of input and a stray "}" is an error. Otherwise runs until a
    matching "}" and running out of input first is an error (missing
    closing brace).

    Returns (container, next_pos) where container is a MOMLDict (pairs
    and/or named blocks) or a MOMLList (bare items) — never a mix,
    enforced by has_pairs/has_items below (Rule 5).
    """
    pairs = MOMLDict()
    items = MOMLList()
    has_pairs = False
    has_items = False

    while True:
        if pos >= len(lines):
            if top_level:
                break
            raise MOMLError("Missing closing '}' — unexpected end of input")

        line = lines[pos]
        line_no = pos + 1  # statements are 1-indexed for messages; see _preprocess

        if _CLOSE.match(line):
            if top_level:
                raise MOMLError(f"Unexpected '}}' at top level (line {line_no})")
            pos += 1
            break

        m = _BLOCK_OPEN.match(line)
        if m:
            if has_items:
                raise MOMLError(
                    f"Cannot mix a named block with bare items in the same "
                    f"block (line {line_no}): {line!r}"
                )
            star = m.group(1) == "*"
            name = m.group(2)
            child, pos = _parse_block(lines, pos + 1, top_level=False)
            pairs[name] = child
            if star:
                if pairs.active is not None:
                    raise MOMLError(
                        f"Duplicate active marker '*' in block (line {line_no}): "
                        f"already active is {pairs.active!r}"
                    )
                pairs.active = name
            has_pairs = True
            continue

        pr = _split_pair(line)
        if pr is not None:
            if has_items:
                raise MOMLError(
                    f"Cannot mix key=value pairs with bare items in the same "
                    f"block (line {line_no}): {line!r}"
                )
            star, key, raw_value = pr
            value = _parse_assignment_value(raw_value, line_no)
            pairs[key] = value
            if star:
                if pairs.active is not None:
                    raise MOMLError(
                        f"Duplicate active marker '*' in block (line {line_no}): "
                        f"already active is {pairs.active!r}"
                    )
                pairs.active = key
            has_pairs = True
            pos += 1
            continue

        # Bare item — only valid in a bare-item block (Rule 5).
        if has_pairs:
            raise MOMLError(
                f"Cannot mix bare items with key=value pairs / blocks in the "
                f"same block (line {line_no}): {line!r}"
            )
        text = line
        star = text.startswith("*")
        if star:
            text = text[1:].strip()
        value = _parse_scalar_text(text.strip())
        items.append(value)
        if star:
            if items.active is not None:
                raise MOMLError(
                    f"Duplicate active marker '*' in list block (line {line_no}): "
                    f"already active is {items.active!r}"
                )
            items.active = value
        has_items = True
        pos += 1
        continue

    return (items if has_items else pairs), pos


# ── Preprocessing ────────────────────────────────────────────────────────────────

def _preprocess(text: str) -> list[str]:
    """
    Strip full-line comments and blank lines, and left/right-trim each
    remaining line.

    Unlike MOML V1, comment detection needs no quote-awareness at all:
    Rule 4 restricts "#" comments to lines whose first non-whitespace
    character is "#" — an inline "#" (`hotkey = #h`) is never a comment
    regardless of quoting, so there is nothing to get wrong here.

    A single `str.strip()` per line is sufficient and safe: it only
    removes whitespace OUTSIDE the statement, and per Rule 2 whitespace
    around "=" and around a bare value is never meaningful — anything
    that must keep its leading/trailing whitespace has to be quoted,
    and quoting protects it before this strip ever runs.
    """
    statements = []
    for raw_line in text.splitlines():
        if raw_line.lstrip().startswith("#"):
            continue
        line = raw_line.strip()
        if line == "":
            continue
        statements.append(line)
    return statements


# ── Public read API ────────────────────────────────────────────────────────────

def loads(text: str) -> MOMLDict:
    """
    Parse a MOML 2.0 string. Returns the root MOMLDict.

    Requires the exact first line to be "# MOML 2.0" (a leading UTF-8 BOM
    is tolerated and stripped, since some Windows editors add one
    silently). This is a content check, independent of file naming — it
    exists so a string that didn't come through load() (built in memory,
    pasted, received over a wire) still can't be silently misparsed as
    the wrong version.
    """
    lines = text.splitlines()
    if not lines or lines[0].lstrip("\ufeff") != HEADER:
        raise MOMLError(f'First line must be exactly: {HEADER!r}')
    statements = _preprocess("\n".join(lines[1:]))
    root, _ = _parse_block(statements, 0, top_level=True)
    if not isinstance(root, MOMLDict):
        # A file consisting entirely of bare items has no precedent in the
        # spec's own examples (every one shows key/value pairs at the root).
        # Wrapping rather than rejecting keeps loads() total for any input
        # that _parse_block can legally produce.
        raise MOMLError(
            "Root of a MOML 2.0 document must be a pairs block, not bare items"
        )
    return root


def load(path: str | Path) -> MOMLDict:
    """
    Parse a MOML 2.0 file. Returns the root MOMLDict.

    Requires the path to end in .m2. This is deliberate and separate
    from the header check inside loads(): MOML V1 (.moml) and MOML V2
    (.m2) are explicitly a frozen/never-migrate pairing per project
    decision — no app is meant to open the other version's files, ever,
    and a wrong extension is the cheap, visible signal that catches a
    mistake (a renamed file, a copy-paste error, a glob pattern that
    matched too broadly) before the content is even read.
    """
    path = Path(path)
    if path.suffix.lower() != ".m2":
        raise MOMLError(f"MOML 2.0 files must use the .m2 extension: {path}")
    return loads(path.read_text(encoding="utf-8"))


# ── Public write API ────────────────────────────────────────────────────────────

def _needs_quoting(s: str) -> bool:
    return ('"' in s) or ("," in s) or (s != s.strip()) or ("\n" in s) or ("\t" in s)


def _bare_item_needs_quoting(s: str) -> bool:
    """
    Quoting need for a value that will occupy an ENTIRE physical line —
    a bare-item block element — which is stricter than an inline scalar
    or list element. For those, the physical line always starts with a
    key name, so nothing about the value's own first or last character
    matters to the grammar. A bare-item line has no key: the value IS
    the whole line, so anything that would make that line match a
    structural pattern on the next read must be quoted here.

    This checks the shapes directly (matching the same _CLOSE and
    _BLOCK_OPEN patterns the parser itself uses, plus the things those
    patterns don't cover — a leading '#', a leading '*', and an
    embedded '=') rather than enumerating "starts with this character"
    cases. That distinction matters: an early version of this function
    (and, independently, another MOML 2.0 implementation reviewed
    against this one) checked literal prefixes and exact strings and
    correctly quoted "*star", "#tag", and "}" alone — but missed two
    further shapes that don't announce themselves at the start of the
    string: "nested {" (ends in an unquoted "{", misread as a block
    open) and "x = y" (contains an unquoted "=", misread as a pair —
    which additionally needed `_split_pair` itself fixed to stop
    finding "=" naively, see the module docstring's NOTE ON GAPS).
    Checking against the actual patterns and the actual delimiter
    character closes both gaps by construction rather than by
    enumerating one more dangerous shape each time one is found.
    """
    if s == "":
        return True
    if _needs_quoting(s):
        return True
    if s.startswith("#"):       # would be swallowed as a full-line comment
        return True
    if s.startswith("*"):       # would be misread as an active-marker prefix
        return True
    if "=" in s:                # would collide with pair-detection (x = y)
        return True
    if _CLOSE.match(s):         # exactly "}" -> closes the block early
        return True
    if s == "{":                # safe alone, but *{ collides with block-open parsing when active
        return True
    if _BLOCK_OPEN.match(s):    # ends in unquoted "{" -> misread as a nested block
        return True
    return False


def _escape(s: str) -> str:
    # Backslash first — escaping the other three characters introduces new
    # backslashes, and escaping those too would double-escape them.
    return (
        s.replace("\\", "\\\\")
         .replace('"', '\\"')
         .replace("\n", "\\n")
         .replace("\t", "\\t")
    )


def _require_str(value: Any, context: str) -> None:
    """
    Rule 1 says type conversion belongs to the gatekeeper, not to MOML —
    but that was previously enforced only by discipline: nothing stopped
    a gatekeeper bug from handing the writer {"timeout": 45} instead of
    {"timeout": "45"}, and when that happened the failure was an ordinary
    Python TypeError from deep inside string-membership checks like
    '"' in s, with a message that had nothing to do with MOML at all.

    Checking here, once, before either leaf serializer touches the
    value, turns an architecture RULE into something that actually
    fails loudly and specifically when violated, rather than relying on
    every future call site remembering to convert first.
    """
    if not isinstance(value, str):
        raise MOMLError(
            f"MOML 2.0 scalar must be str, got {type(value).__name__} "
            f"({value!r}) for {context}. Rule 1: type conversion belongs "
            f"to the gatekeeper — convert application values to strings "
            f"before handing them to dumps()/dump()."
        )


def _validate_name(name: Any, context: str = "key/block name") -> str:
    """Validate a MOML 2.0 key or named-block name before writing."""
    if not isinstance(name, str):
        raise MOMLError(f"MOML 2.0 {context} must be a string")
    if name == "":
        raise MOMLError(f"Invalid MOML 2.0 {context}: empty name")
    if any(ch.isspace() for ch in name):
        raise MOMLError(f"Invalid MOML 2.0 {context}: {name!r} contains whitespace")
    if name.startswith("#") or name.startswith("*"):
        raise MOMLError(f"Invalid MOML 2.0 {context}: {name!r} starts with structural syntax")
    if any(ch in name for ch in '= {}"'):
        raise MOMLError(f"Invalid MOML 2.0 {context}: {name!r} contains structural syntax")
    return name


def _write_value(s: str) -> str:
    """
    Serialize one scalar for use as an INLINE-LIST ELEMENT or a scalar
    assignment's right-hand side. An empty string is quoted here — this
    is the "" form Rule 7 requires for a one-element list, and the only
    way an empty element survives being read back as an element rather
    than being silently dropped (see NOTE ON GAPS in the module
    docstring).

    Not used for bare-item block elements — see _bare_item_needs_quoting
    for why those need a stricter check.
    """
    _require_str(s, "an inline-list element or scalar value")
    if s == "":
        return '""'
    if _needs_quoting(s):
        return '"' + _escape(s) + '"'
    return s


def _write_bare_item(s: str) -> str:
    """Serialize one BARE-ITEM block element (occupies a whole physical line)."""
    _require_str(s, "a bare-item block element")
    if s == "":
        return '""'
    if _bare_item_needs_quoting(s):
        return '"' + _escape(s) + '"'
    return s


def _dump_block(block, indent: int) -> list[str]:
    pad = "    " * indent
    lines: list[str] = []

    if isinstance(block, MOMLList):
        # Star only the FIRST item equal to block.active, not every item
        # equal to it. .active stores the ACTIVE VALUE, not a position, so
        # two identical items with only one marked active (a legal file —
        # `a` then `*a` — has just one literal "*") were both being
        # compared against block.active by value and both written with a
        # star, producing two starred lines that the parser's own
        # duplicate-active check then correctly rejected. A valid parse
        # became unreadable on its own round trip. Since the data model
        # can't distinguish "the first a" from "the second a" (both are
        # simply the string "a"), writing the star on the first match is
        # the only deterministic choice available, and it's the one that
        # makes write-then-read stable, which is the property this bug
        # broke.
        active_written = False
        for item in block:
            star = ""
            if block.active is not None and not active_written and item == block.active:
                star = "*"
                active_written = True
            lines.append(f"{pad}{star}{_write_bare_item(item)}")
        return lines

    active = getattr(block, "active", None)
    for key, value in block.items():
        key = _validate_name(key)
        star = "*" if key == active else ""
        if isinstance(value, (MOMLDict, MOMLList)):
            lines.append(f"{pad}{star}{key} {{")
            lines.extend(_dump_block(value, indent + 1))
            lines.append(f"{pad}}}")
        elif isinstance(value, list):
            # Plain list = inline comma list (Rule 6 — never carries .active).
            #
            # A genuine one-element list is padded with a trailing "" before
            # writing. Without this, ["#h"] would write as the bare scalar
            # "dictate_keys = #h" — which is completely valid MOML, but reads
            # back as a STRING, not a list, on the next load. The gatekeeper
            # can (and PathPilot's does) recover from that by wrapping a
            # lone string into a one-item list on read — but that only works
            # if every reader remembers to do it. Padding here means the
            # list survives as a list through moml2 alone, with no
            # gatekeeper cooperation required, matching the convention the
            # spec itself demonstrates ("models = llama4, \"\"").
            #
            # A genuinely empty list ([]) has no representation this
            # preserves — Rule 7 makes an empty right-hand side the empty
            # STRING unconditionally, so there is no bare syntax for "a list
            # with zero elements" distinct from "an empty scalar". An empty
            # list is written as "key =" and comes back as "" on reload; a
            # gatekeeper that needs zero-length-list-vs-empty-string
            # distinguished has to track that itself. Rare enough in
            # practice not to warrant inventing new syntax for it.
            elements = list(value)
            if len(elements) == 1:
                elements = elements + [""]
            if elements:
                joined = ", ".join(_write_value(v) for v in elements)
                lines.append(f"{pad}{star}{key} = {joined}")
            else:
                lines.append(f"{pad}{star}{key} =")
        else:
            # Plain string scalar. Empty is written bare per Rule 10 item 8
            # ("prefer key = for empty scalar values") — NOT as key = "",
            # which is reserved for the list-element empty-marker form.
            if value == "":
                lines.append(f"{pad}{star}{key} =")
            else:
                lines.append(f"{pad}{star}{key} = {_write_value(value)}")

    return lines


def dumps(config: MOMLDict) -> str:
    """Serialize a MOMLDict to a MOML 2.0 string, with the mandatory header."""
    body = _dump_block(config, 0)
    return "\n".join([HEADER, ""] + body) + "\n"


def dump(config: MOMLDict, path: str | Path) -> None:
    """
    Serialize a MOMLDict and write it to a file, LF line endings.

    Requires the path to end in .m2 — see load() for why this is
    enforced rather than left to convention.
    """
    path = Path(path)
    if path.suffix.lower() != ".m2":
        raise MOMLError(f"MOML 2.0 files must use the .m2 extension: {path}")
    path.write_text(dumps(config), encoding="utf-8", newline="\n")


# ── Convenience accessors ────────────────────────────────────────────────────────

def get(config, *keys: str, default: Any = None) -> Any:
    """Safe nested key access: get(config, 'gui', 'width', default=360)."""
    node = config
    for key in keys:
        if not isinstance(node, dict) or key not in node:
            return default
        node = node[key]
    return node


def get_active(block):
    """
    Returns (key, value) for the active entry of a MOMLDict, or the
    active value alone for a MOMLList (as (value, value) for a
    consistent two-tuple shape), or (None, None) if nothing is active
    or `block` is neither container type.
    """
    if isinstance(block, MOMLDict):
        if block.active is not None:
            return block.active, block.get(block.active)
        return None, None
    if isinstance(block, MOMLList):
        if block.active is not None:
            return block.active, block.active
        return None, None
    return None, None
