"""
fuzz_tests.py — randomized round-trip fuzzing for moml2.py
=============================================================

Structured for MOML 2.0 Studio's "Run Fuzz Tests" panel: each category
returns a (passed, failed, failure_details) tuple so the GUI can show a
per-category pass/fail count the same way it shows spec_tests.py's
per-rule breakdown.

Every category here targets a SPECIFIC bug class that was found during
this session's review rounds — this file exists because hand-picked
examples caught four real bugs, and a 10,000-file randomized batch
caught a fifth that no hand-picked example had found. Fuzzing is not a
substitute for the spec-example suite; it's what catches the case
nobody thought to write by hand.

Run standalone: python3 fuzz_tests.py [N]
"""

from __future__ import annotations
import random
import string
import sys

import moml2
from moml2 import MOMLDict, MOMLList, MOMLError


class FuzzResult:
    def __init__(self, category: str):
        self.category = category
        self.passed = 0
        self.failed = 0
        self.failures: list[str] = []

    def ok(self):
        self.passed += 1

    def fail(self, detail: str):
        self.failed += 1
        if len(self.failures) < 10:  # cap stored detail, the count is what matters
            self.failures.append(detail)

    @property
    def total(self):
        return self.passed + self.failed


# ── Category 1: scalar round-trip with dangerous characters ────────────────

_DANGEROUS_CHARS = ['"', "#", "*", "=", "{", "}", ",", "\\", "\n", "\t", " "]


def _random_scalar(rng: random.Random, min_len=0, max_len=12) -> str:
    n = rng.randint(min_len, max_len)
    pool = string.ascii_letters + string.digits + "".join(_DANGEROUS_CHARS)
    return "".join(rng.choice(pool) for _ in range(n))


def fuzz_scalar_roundtrip(n: int, seed: int) -> FuzzResult:
    """
    Random strings — built from a pool weighted toward the characters
    that matter (quote, hash, star, equals, braces, comma, backslash,
    whitespace) — as a plain assignment value. Every one must come back
    byte-identical after dumps() -> loads(), regardless of what it
    contains: this is Rule 1/2/3's actual promise under adversarial
    content, not just the worked examples.
    """
    rng = random.Random(seed)
    r = FuzzResult("Scalar round-trip (dangerous characters)")
    for _ in range(n):
        val = _random_scalar(rng)
        try:
            d = MOMLDict({"x": val})
            back = moml2.loads(moml2.dumps(d))
            if back["x"] == val:
                r.ok()
            else:
                r.fail(f"{val!r} -> {back['x']!r}")
        except MOMLError as e:
            # A handful of characters (a lone unmatched structural
            # sequence) can legitimately be unrepresentable as a BARE
            # value in ways that still round-trip fine once quoted —
            # moml2 quotes automatically, so a clean MOMLError here
            # would indicate a real gap. Record it as a failure; there
            # is no scalar value this category should be unable to
            # represent, since _write_value always has a quoted form
            # available.
            r.fail(f"{val!r} raised unexpectedly: {e}")
    return r


# ── Category 2: bare-item structural collisions ─────────────────────────────

def fuzz_bare_item_collisions(n: int, seed: int) -> FuzzResult:
    """
    The exact bug class found in this session's second and third review
    rounds: a bare-item block value that would, if written unquoted,
    read back as something other than itself — a comment, an active
    marker, a pair, a block close, a nested block open. Generates
    values deliberately biased toward these shapes (not just random
    noise) and confirms every one survives a write/read cycle as a
    single, unchanged list item.

    Includes a fixed regression case for the narrowest gap found so
    far: a value that is EXACTLY the single character "{", marked
    active. That value alone doesn't collide with anything — only the
    combination of the active-marker "*" plus that specific one-
    character value does, because the literal "*" supplies the "at
    least one token character" _BLOCK_OPEN needs before its mandatory
    closing brace. A value this short and this specific is unlikely to
    turn up by chance even across thousands of random trials, so it is
    checked explicitly rather than left to probability.
    """
    rng = random.Random(seed)
    r = FuzzResult("Bare-item structural collisions")

    # Fixed regression case, checked every run regardless of RNG luck.
    try:
        lst = MOMLList(["{"])
        lst.active = "{"
        back = moml2.loads(moml2.dumps(MOMLDict({"items": lst})))
        if list(back["items"]) == ["{"] and back["items"].active == "{":
            r.ok()
        else:
            r.fail(f"active '{{' regression: got {list(back['items'])!r} active={back['items'].active!r}")
    except MOMLError as e:
        r.fail(f"active '{{' regression raised unexpectedly: {e}")

    templates = [
        lambda: "#" + _random_scalar(rng, 0, 8),
        lambda: "*" + _random_scalar(rng, 0, 8),
        lambda: _random_scalar(rng, 1, 6) + " = " + _random_scalar(rng, 1, 6),
        lambda: "}",
        lambda: _random_scalar(rng, 1, 8) + " {",
        lambda: "{",  # the specific short value the regression case targets
        lambda: _random_scalar(rng, 0, 10),  # plain, control case
    ]
    for _ in range(n):
        val = rng.choice(templates)()
        active = rng.random() < 0.3  # sometimes mark THIS value active too
        try:
            lst = MOMLList([val])
            if active:
                lst.active = val
            d = MOMLDict({"items": lst})
            back = moml2.loads(moml2.dumps(d))
            got = list(back["items"]) if "items" in back and isinstance(back["items"], list) else None
            ok = got == [val]
            if active:
                ok = ok and back["items"].active == val
            if ok:
                r.ok()
            else:
                r.fail(f"{val!r} (active={active}) -> items={got!r}")
        except MOMLError as e:
            r.fail(f"{val!r} (active={active}) raised unexpectedly: {e}")
    return r


# ── Category 3: duplicate-value active markers ──────────────────────────────

def fuzz_duplicate_active(n: int, seed: int) -> FuzzResult:
    """
    The bug an external reviewer's 10,000-file batch found: identical
    bare-item values with only one marked active used to have the
    writer star EVERY matching occurrence (comparing by value, not
    position), producing a file its own reader rejected as a duplicate
    active marker. Forces collisions with a deliberately narrow
    3-letter alphabet, exactly as that batch did.
    """
    rng = random.Random(seed)
    r = FuzzResult("Duplicate-value active markers")
    alphabet = ["a", "b", "c"]
    for _ in range(n):
        length = rng.randint(1, 6)
        items = [rng.choice(alphabet) for _ in range(length)]
        active_idx = rng.choice(range(length)) if rng.random() < 0.7 else None
        try:
            lst = MOMLList(items)
            if active_idx is not None:
                lst.active = items[active_idx]
            d = MOMLDict({"items": lst})
            back = moml2.loads(moml2.dumps(d))
            ok = list(back["items"]) == items
            if active_idx is not None:
                ok = ok and back["items"].active == items[active_idx]
            if ok:
                r.ok()
            else:
                r.fail(f"items={items} active_idx={active_idx} -> {list(back['items'])!r}")
        except MOMLError as e:
            r.fail(f"items={items} active_idx={active_idx} raised: {e}")
    return r


# ── Category 4: nested structure round-trip ─────────────────────────────────

def _random_structure(rng: random.Random, depth: int):
    """Build a random nested MOMLDict/MOMLList/scalar tree."""
    if depth <= 0 or rng.random() < 0.5:
        return _random_scalar(rng, 0, 10)
    if rng.random() < 0.5:
        d = MOMLDict()
        keys = set()
        for _ in range(rng.randint(1, 4)):
            key = "".join(rng.choices(string.ascii_lowercase, k=rng.randint(1, 6)))
            if key in keys:
                continue
            keys.add(key)
            d[key] = _random_structure(rng, depth - 1)
        if d and rng.random() < 0.5:
            d.active = rng.choice(list(d.keys()))
        return d
    items = [_random_scalar(rng, 0, 8) for _ in range(rng.randint(1, 5))]
    lst = MOMLList(items)
    if items and rng.random() < 0.5:
        lst.active = rng.choice(items)
    return lst


def _structures_equal(a, b) -> bool:
    if isinstance(a, MOMLDict) and isinstance(b, MOMLDict):
        if a.active != b.active or set(a.keys()) != set(b.keys()):
            return False
        return all(_structures_equal(a[k], b[k]) for k in a)
    if isinstance(a, MOMLList) and isinstance(b, MOMLList):
        return (
            a.active == b.active
            and len(a) == len(b)
            and all(_structures_equal(x, y) for x, y in zip(a, b))
        )
    if isinstance(a, list) and isinstance(b, list) and not isinstance(a, MOMLList):
        return a == b
    return a == b


def fuzz_nested_structures(n: int, seed: int, max_depth: int = 3) -> FuzzResult:
    """
    Randomly generated nested dict/list trees, up to a few levels deep,
    with random active markers at each level. Confirms the WHOLE
    structure — not just one leaf value — survives dumps() -> loads()
    unchanged, catching any interaction between nesting and the fixes
    above that a single-value test wouldn't exercise.
    """
    rng = random.Random(seed)
    r = FuzzResult("Nested structure round-trip")
    for _ in range(n):
        root = MOMLDict()
        root["root"] = _random_structure(rng, max_depth)
        try:
            back = moml2.loads(moml2.dumps(root))
            if _structures_equal(root, back):
                r.ok()
            else:
                r.fail(f"structure mismatch (seed-dependent, depth {max_depth})")
        except MOMLError as e:
            r.fail(f"raised unexpectedly: {e}")
    return r


# ── Category 5: key/name validation never silently corrupts ────────────────

_LEADING_DANGEROUS = ("#", "*")  # only dangerous as the FIRST character
_ANYWHERE_DANGEROUS = set('="{} \t')  # dangerous anywhere in the string


def _is_dangerous_key(key: str) -> bool:
    """
    Matches moml2._validate_name's ACTUAL rules, not a broader guess.
    An earlier version of this function flagged '#' and '*' as
    dangerous ANYWHERE in a key and additionally flagged backslash —
    both wrong, confirmed by direct testing: 'b5fxzpF7S#' (# mid-string,
    not first), 'J7K*TS' (* mid-string), and 'FW\\nFbB0' (backslash) all
    round-trip correctly through the real parser, because comment
    detection and active-marker stripping only ever look at a line's
    FIRST character, and backslash was never treated specially outside
    quotes at all (Rule 3). Getting this fuzz test's own expectations
    wrong would have meant it failing against a CORRECT parser — the
    bug would have been in the test, not in moml2.py.
    """
    if key == "":
        return True
    if key[0] in _LEADING_DANGEROUS:
        return True
    return any(c in _ANYWHERE_DANGEROUS for c in key)


def fuzz_key_validation(n: int, seed: int) -> FuzzResult:
    """
    The bug class from the third review round: a dict key the grammar
    can't safely round-trip must be REJECTED at write time, never
    silently written and corrupted on the next read. For each random
    key, either it contains no dangerous character (and must round-trip
    correctly) or it is dangerous per `_is_dangerous_key` above (and
    dumps() must raise MOMLError, not produce a file that reads back
    differently).
    """
    rng = random.Random(seed)
    r = FuzzResult("Key/name validation (no silent corruption)")
    pool = string.ascii_letters + string.digits + "#*=\"{}\\, \t"
    for _ in range(n):
        length = rng.randint(1, 10)
        key = "".join(rng.choice(pool) for _ in range(length))
        is_dangerous = _is_dangerous_key(key)
        try:
            back = moml2.loads(moml2.dumps(MOMLDict({key: "v"})))
            if is_dangerous:
                r.fail(f"{key!r} should have raised MOMLError but wrote+reread as {dict(back)!r}")
            elif back.get(key) == "v":
                r.ok()
            else:
                r.fail(f"{key!r} round-tripped to a different structure: {dict(back)!r}")
        except MOMLError:
            if is_dangerous:
                r.ok()
            else:
                r.fail(f"{key!r} should NOT have raised MOMLError but did")
    return r


CATEGORIES = [
    fuzz_scalar_roundtrip,
    fuzz_bare_item_collisions,
    fuzz_duplicate_active,
    fuzz_nested_structures,
    fuzz_key_validation,
]


def run_all(n_per_category: int = 300, base_seed: int = 1) -> list[FuzzResult]:
    """Run every category and return their results, in a fixed order."""
    return [fn(n_per_category, base_seed + i) for i, fn in enumerate(CATEGORIES)]


if __name__ == "__main__":
    n = int(sys.argv[1]) if len(sys.argv) > 1 else 1000
    print(f"Running {n} trials per category...\n")
    results = run_all(n_per_category=n)
    total_pass = sum(r.passed for r in results)
    total_fail = sum(r.failed for r in results)
    for r in results:
        status = "PASS" if r.failed == 0 else "FAIL"
        print(f"  [{status}]  {r.category:<45} {r.passed}/{r.total}")
        for detail in r.failures:
            print(f"           - {detail}")
    print(f"\n{'='*60}")
    print(f"TOTAL: {total_pass}/{total_pass + total_fail} passed")
    print(f"{'='*60}")
    if total_fail:
        sys.exit(1)
