"""
sample_gatekeeper.py — a worked MOML V2 gatekeeper
=====================================================

This is the pattern from MOML_GATEKEEPER.md, rebuilt against moml2.py.
It is deliberately written to show what changes and what doesn't:

WHAT DOESN'T CHANGE — the architecture:
    The app never imports moml2 directly. Every read goes through a
    typed accessor; every write goes through a single Map-shaped save
    function. That discipline is identical to V1's gatekeeper — it was
    never a V1-specific idea, it's the actual point of a gatekeeper.

WHAT CHANGES — how much work the accessors have to do:
    Under V1, GK_ToHex() had to DETECT that a value arrived as an
    Integer (proof that MOML had already silently damaged it) and
    RECONSTRUCT the zero-padded original by guessing. That is repair
    work, and it only exists because V1 could produce a wrong value in
    the first place.

    Under V2, MOML never produces a wrong value — "085041" comes back
    as the string "085041", byte for byte. GK_ToHex() below is a type
    cast, not a repair. The function is shorter because the bug it used
    to work around doesn't exist to work around.

    Same story for GK_ToKey() — V1 needed a whole angle-bracket-token
    translation layer because "#" could not survive being stored in
    the file at all. Under V2, "#h" is stored as "#h". The function
    below still exists (an app may want *its own* reasons to keep a
    readable token form in config, and that's a legitimate choice) but
    it is no longer LOAD-BEARING the way it was under V1 — if you
    skipped it entirely, "#h" would still round-trip correctly.

Run: python3 sample_gatekeeper.py
"""

import moml2
from moml2 import MOMLDict, MOMLList

CONFIG_FILE = "sample_config.m2"


# ── Internal I/O — the only functions that touch moml2 directly ────────────────

def _load() -> MOMLDict:
    return moml2.load(CONFIG_FILE)


def _save(config: MOMLDict) -> None:
    moml2.dump(config, CONFIG_FILE)


# ── Normalizers — app-facing type conversion ────────────────────────────────────
#
# Every one of these takes the raw string moml2 hands back and produces
# the type the app actually wants. This is the entire gatekeeper
# obligation under V2: cast on the way in, stringify on the way out.
# There is no repair step, because there is nothing to repair.

def to_bool(v: str, fallback: bool = False) -> bool:
    if v is None or v == "":
        return fallback
    return v.strip().lower() in ("true", "1", "yes")


def from_bool(v: bool) -> str:
    return "true" if v else "false"


def to_int(v: str, fallback: int = 0) -> int:
    try:
        return int(v)
    except (TypeError, ValueError):
        return fallback


def from_int(v: int) -> str:
    return str(v)


def to_hex(v: str, fallback: str = "#FFFFFF") -> str:
    """
    Under V1 this function had to detect an Integer and zero-pad it
    back — "085041" arrived already damaged as 85041. Under V2,
    "theme_color" is the string "085041" untouched; this is now a
    presentation helper (prepend '#'), not a recovery mechanism.

    `fallback` is APP-FACING form — already carries '#' — matching what
    this function returns and what DEFAULTS holds. An earlier version
    took a bare "FFFFFF" fallback and re-prepended '#' to it here, which
    was fine for the case where the key is PRESENT but empty (that path
    always ran through this function) but left a real inconsistency for
    the case where the key is ABSENT entirely: get_settings() never
    calls to_hex() at all then, it just returns DEFAULTS["theme_color"]
    directly — so an absent key produced "FFFFFF" (bare) while an empty
    one produced "#FFFFFF" (prefixed), depending on a code path the
    caller has no visibility into. Storing the default already in
    app-facing form makes both paths return the identical value.
    """
    if v is None or v == "":
        return fallback
    return "#" + v


def from_hex(v: str) -> str:
    return v.lstrip("#")


def to_path(v: str) -> str:
    """
    V1 needed this to flip a forward-slash convention back to
    backslashes, because a raw backslash in the file was an escape
    introducer and would corrupt on read. Under V2 (Rule 3), backslash
    is literal outside quotes — "C:\\Users\\Karim" round-trips as
    typed. This function is now the identity function, kept only so
    call sites don't need to change if a future rule ever revisits
    path handling.
    """
    return v


def from_path(v: str) -> str:
    return v


def to_key(v: str) -> str:
    """
    V1 could not store a raw '#', so PathPilot's dictate keys were
    written as angle-bracket tokens (<Win>h) and translated in both
    directions. Under V2, '#h' is stored as '#h' — this function is
    an identity pass-through kept only for symmetry with to_hex/to_path
    above, not because anything needs translating.
    """
    return v


def from_key(v: str) -> str:
    return v


def to_list(v) -> list:
    """
    moml2 now pads a genuine one-element list on write — ["#h"] is
    stored as `#h, ""` so it survives as a list rather than collapsing
    to a scalar on the next read (see moml2.py's NOTE ON GAPS). That
    means this accessor must undo the padding on the way in, or the
    app would see a phantom trailing "" it never asked for, and every
    save-reload cycle would hand it back unchanged rather than growing
    — but it's still an extra element nothing in the app put there.

    This is schema knowledge, not something moml2 does for you: only
    an accessor that KNOWS "dictate_keys is conceptually a list using
    the one-element convention" should strip. A different key might
    legitimately want ["x", ""] preserved as two real elements — moml2
    itself can't tell those two cases apart from the data alone, by
    design (Rule 7's own wording: the gatekeeper "MAY remove" the
    padding, not "always does").

    Strips at MOST one trailing empty, not every trailing empty in a
    while-loop: the writer only ever adds one, so stripping more than
    one would silently discard a second, genuinely-meaningful empty
    element from a hand-edited file.
    """
    if v is None or v == "":
        return []
    if not isinstance(v, (list, MOMLList)):
        return [v]
    result = list(v)
    if len(result) >= 1 and result[-1] == "":
        result.pop()
    return result


# ── Schema-aware read ────────────────────────────────────────────────────────────

DEFAULTS = {
    "enabled": True,
    "max_items": 10,
    "theme_color": "#FFFFFF",
    "log_path": "",
    "dictate_keys": ["#h"],
}


def get_settings() -> dict:
    result = dict(DEFAULTS)

    config = _load()
    s = config.get("settings")
    if not isinstance(s, MOMLDict):
        return result

    if "enabled" in s:
        result["enabled"] = to_bool(s["enabled"], DEFAULTS["enabled"])
    if "max_items" in s:
        result["max_items"] = to_int(s["max_items"], DEFAULTS["max_items"])
    if "theme_color" in s:
        result["theme_color"] = to_hex(s["theme_color"], DEFAULTS["theme_color"])
    if "log_path" in s:
        result["log_path"] = to_path(s["log_path"])
    if "dictate_keys" in s:
        result["dictate_keys"] = [to_key(k) for k in to_list(s["dictate_keys"])]

    return result


# ── Schema-aware write ────────────────────────────────────────────────────────────
#
# Takes a dict keyed by setting name — same Map-shaped call as the
# PathPilot v10.5 GK_SaveSettings() refactor, for the same reason:
# named keys make a transposition bug (the wrong value landing in the
# wrong setting) structurally impossible, where positional parameters
# invite exactly that once there are more than four or five of them.

def save_settings(**vals) -> None:
    config = _load()
    block = config.get("settings")
    if not isinstance(block, MOMLDict):
        block = MOMLDict()

    for key, val in vals.items():
        if key == "enabled":
            block[key] = from_bool(val)
        elif key == "max_items":
            block[key] = from_int(val)
        elif key == "theme_color":
            block[key] = from_hex(val)
        elif key == "log_path":
            block[key] = from_path(val)
        elif key == "dictate_keys":
            block[key] = [from_key(k) for k in val]
        else:
            # An unknown key used to fall through as `block[key] = val`
            # unchanged — meaning an int, bool, None, or anything else a
            # caller passed would reach moml2.dump() untouched and fail
            # there instead, deep inside _write_value(), with a message
            # about MOML's Rule 1 rather than about the actual mistake
            # (a typo'd setting name, most likely — the case this is
            # actually for). A gatekeeper's whole job is to be the place
            # a schema mismatch gets caught with a message that points at
            # the real problem; letting it pass through defeats that.
            raise ValueError(
                f"save_settings() received unknown setting {key!r}. "
                f"Known settings: {', '.join(sorted(DEFAULTS))}. Add an "
                f"explicit branch above for a genuinely new setting "
                f"rather than passing it through unchecked."
            )

    config["settings"] = block
    _save(config)


# ── Demonstration ────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import os

    # Start from a hand-written file, exactly as a human would author it —
    # bare values, no quotes, no escapes, the hex colour written plainly.
    # The "# MOML 2.0" header is mandatory (moml2.loads/load reject its
    # absence) — it's the content-level half of the V1/V2 isolation; the
    # .m2 extension enforced by load()/dump() is the other half.
    seed = (
        "# MOML 2.0\n"
        "\n"
        "settings {\n"
        "    enabled = true\n"
        "    max_items = 10\n"
        "    theme_color = 085041\n"
        r"    log_path = C:\Users\Karim\Documents" "\n"
        "    dictate_keys = #h, +#h\n"
        "}\n"
    )
    with open(CONFIG_FILE, "w", encoding="utf-8") as f:
        f.write(seed)

    print("── seed file (hand-authored form) ──")
    print(seed)

    settings = get_settings()
    print("── get_settings() result ──")
    for k, v in settings.items():
        print(f"  {k:14} = {v!r}  ({type(v).__name__})")

    assert settings["theme_color"] == "#085041", "leading zero must survive"
    assert settings["dictate_keys"] == ["#h", "+#h"]
    assert settings["log_path"] == r"C:\Users\Karim\Documents"
    print("\n  all assertions passed — no leading zero lost, no path corrupted")

    # Round trip through a save, using values the app already holds.
    save_settings(
        enabled=False,
        max_items=25,
        theme_color="#0f0f0f",
        log_path=r"D:\Notes",
        dictate_keys=["#h"],
    )

    print("\n── config file after save_settings() ──")
    with open(CONFIG_FILE, encoding="utf-8") as f:
        print(f.read())

    reloaded = get_settings()
    assert reloaded["theme_color"] == "#0f0f0f"
    assert reloaded["max_items"] == 25
    assert reloaded["enabled"] is False
    print("  re-read after save matches what was saved — round trip confirmed")

    os.remove(CONFIG_FILE)
