"""ORCH-076 (ORCH-52c): unit tests for the unified frontmatter contract.

Covers TC-01..TC-07 of docs/work-items/ORCH-076/04-test-plan.yaml:
  * writer (render/round-trip), validator (full / partial schema, strict on/off),
  * reader contract preserved (read_frontmatter_value), never-raise on bad input.

The whole module honours a never-raise contract (NFR-2): no input shape may raise.
"""

import os
import tempfile

os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")

from src import frontmatter as fm  # noqa: E402
from src.frontmatter import (  # noqa: E402
    REQUIRED_FIELDS,
    FrontmatterParse,
    SchemaValidation,
    maybe_warn_schema,
    parse_frontmatter,
    parse_frontmatter_dict,
    read_frontmatter,
    read_frontmatter_value,
    render_frontmatter,
    strip_frontmatter,
    validate_schema,
    write_frontmatter,
)


def _full_schema():
    return {
        "work_item": "ORCH-076",
        "stage": "review",
        "author_agent": "reviewer",
        "status": "APPROVED",
        "created_at": "2026-06-09",
        "model_used": "claude-opus-4-8",
    }


# --------------------------------------------------------------------------- #
# TC-01 — writer serialises a mapping into canonical leading YAML-frontmatter
#         readable by the existing parsers (split("---", 2) + yaml.safe_load).
# --------------------------------------------------------------------------- #
def test_tc01_render_frontmatter_is_canonical_and_reparseable():
    out = render_frontmatter({"verdict": "APPROVED", "work_item": "ORCH-076"}, "body text")
    assert out.startswith("---\n")
    # Existing parser shape: split on '---' into 3 segments + yaml.safe_load.
    parts = out.split("---", 2)
    assert len(parts) == 3
    import yaml
    data = yaml.safe_load(parts[1])
    assert data["verdict"] == "APPROVED"
    assert data["work_item"] == "ORCH-076"
    # Body is preserved verbatim after the closing fence.
    assert parts[2].lstrip("\n") == "body text"
    # And our own primitive round-trips it.
    assert parse_frontmatter(out).data == {"verdict": "APPROVED", "work_item": "ORCH-076"}


def test_tc01_render_empty_body_default():
    out = render_frontmatter({"a": 1})
    assert out == "---\na: 1\n---\n"


# --------------------------------------------------------------------------- #
# TC-02 — round-trip: writer -> reader read_frontmatter_value yields same values.
# --------------------------------------------------------------------------- #
def test_tc02_write_then_read_roundtrip():
    data = _full_schema()
    with tempfile.TemporaryDirectory() as d:
        path = os.path.join(d, "12-review.md")
        assert write_frontmatter(path, data, "# Review body") is True
        for key, val in data.items():
            assert read_frontmatter_value(path, key) == val
        # Whole-mapping read matches too.
        assert read_frontmatter(path) == data


def test_tc02_render_parse_dict_roundtrip():
    data = _full_schema()
    rendered = render_frontmatter(data, "body")
    assert parse_frontmatter_dict(rendered) == data


# --------------------------------------------------------------------------- #
# TC-03 — validator: full schema -> valid=True, no missing fields.
# --------------------------------------------------------------------------- #
def test_tc03_validate_full_schema_valid():
    res = validate_schema(_full_schema())
    assert isinstance(res, SchemaValidation)
    assert res.valid is True
    assert res.missing == []


# --------------------------------------------------------------------------- #
# TC-04 — validator: partial schema -> valid=False with the missing list,
#         WITHOUT raising (warning-only by default).
# --------------------------------------------------------------------------- #
def test_tc04_validate_partial_schema_lists_missing():
    res = validate_schema({"work_item": "ORCH-076", "stage": "review"})
    assert res.valid is False
    # The four absent required fields are reported (order = REQUIRED_FIELDS).
    assert set(res.missing) == set(REQUIRED_FIELDS) - {"work_item", "stage"}
    assert res.missing == [f for f in REQUIRED_FIELDS if f in res.missing]


def test_tc04_blank_and_none_count_as_missing():
    data = _full_schema()
    data["status"] = ""        # blank -> missing
    data["model_used"] = None  # None -> missing
    res = validate_schema(data)
    assert res.valid is False
    assert set(res.missing) == {"status", "model_used"}


# --------------------------------------------------------------------------- #
# TC-05 — never-raise: writer + validator on broken input return a safe value.
# --------------------------------------------------------------------------- #
def test_tc05_validate_non_mapping_never_raises():
    for bad in (None, "not a mapping", 123, ["a", "b"]):
        res = validate_schema(bad)  # type: ignore[arg-type]
        assert res.valid is False
        assert set(res.missing) == set(REQUIRED_FIELDS)


def test_tc05_parse_broken_inputs_never_raise():
    # No frontmatter.
    p = parse_frontmatter("just prose, no fence")
    assert p == FrontmatterParse()
    assert p.data == {} and p.has_block is False
    # Unterminated block.
    p = parse_frontmatter("---\nkey: val\nno closing fence")
    assert p.has_block is True and p.malformed is True and p.data == {}
    # Bad YAML.
    p = parse_frontmatter("---\nkey: : :\n bad\n---\n")
    assert p.has_block is True and p.yaml_error is not None and p.data == {}
    # Non-mapping scalar frontmatter.
    p = parse_frontmatter("---\njust a string\n---\nbody")
    assert p.has_block is True and p.data == {}
    # Non-string input.
    assert parse_frontmatter(None).data == {}  # type: ignore[arg-type]
    assert parse_frontmatter_dict(12345) == {}  # type: ignore[arg-type]


def test_tc05_write_to_unwritable_path_returns_false():
    # A path under a non-existent directory cannot be opened -> False, no raise.
    ok = write_frontmatter("/nonexistent-dir-xyz/cannot/12-review.md", {"a": 1})
    assert ok is False


def test_tc05_render_unserialisable_degrades_to_body():
    class Bad:
        pass

    out = render_frontmatter({"x": Bad()}, "fallback-body")
    # yaml cannot serialise an arbitrary object -> degrade to the body, never raise.
    assert out == "fallback-body"


def test_tc05_read_missing_file_returns_empty():
    assert read_frontmatter("/no/such/file.md") == {}
    assert read_frontmatter_value("/no/such/file.md", "verdict") is None


# --------------------------------------------------------------------------- #
# TC-06 — reader read_frontmatter_value keeps its previous contract.
# --------------------------------------------------------------------------- #
def test_tc06_reader_contract_preserved():
    with tempfile.TemporaryDirectory() as d:
        path = os.path.join(d, "doc.md")
        with open(path, "w", encoding="utf-8") as f:
            f.write("---\nverdict: Approved\nempty:\n---\nbody\n")
        # strip + case preserved.
        assert read_frontmatter_value(path, "verdict") == "Approved"
        # empty value -> None.
        assert read_frontmatter_value(path, "empty") is None
        # absent key -> None.
        assert read_frontmatter_value(path, "missing") is None

    # No frontmatter -> None.
    with tempfile.TemporaryDirectory() as d:
        path = os.path.join(d, "doc.md")
        with open(path, "w", encoding="utf-8") as f:
            f.write("no frontmatter here\n")
        assert read_frontmatter_value(path, "verdict") is None


def test_tc06_reader_strips_whitespace():
    with tempfile.TemporaryDirectory() as d:
        path = os.path.join(d, "doc.md")
        with open(path, "w", encoding="utf-8") as f:
            f.write('---\nverdict: "  PASS  "\n---\n')
        assert read_frontmatter_value(path, "verdict") == "PASS"


# --------------------------------------------------------------------------- #
# TC-07 — kill-switch: strict False (default) is inert; strict True signals
#         invalidity. maybe_warn_schema never changes a verdict either way.
# --------------------------------------------------------------------------- #
def test_tc07_maybe_warn_schema_default_warning_only(monkeypatch, caplog):
    monkeypatch.setattr(fm, "logger", fm.logger)
    from src.config import settings
    monkeypatch.setattr(settings, "frontmatter_validation_strict", False)
    incomplete = render_frontmatter({"verdict": "APPROVED"}, "body")
    res = maybe_warn_schema(incomplete, "review report")
    # Validation still reports invalidity (the data IS incomplete)...
    assert res.valid is False
    assert "model_used" in res.missing
    # ...but the helper is inert: it returns a value, it does not raise / block.


def test_tc07_strict_flag_visible_to_helper(monkeypatch):
    from src.config import settings
    # Full schema -> valid regardless of the flag.
    monkeypatch.setattr(settings, "frontmatter_validation_strict", True)
    res_full = maybe_warn_schema(render_frontmatter(_full_schema(), "b"), "doc")
    assert res_full.valid is True
    # Incomplete -> invalid; strict True does not raise, just signals.
    res_partial = maybe_warn_schema(render_frontmatter({"stage": "review"}, "b"), "doc")
    assert res_partial.valid is False


def test_tc07_maybe_warn_schema_on_garbage_is_inert():
    # Never-raise: a non-string / no-frontmatter input returns a SchemaValidation
    # (reporting the missing fields) WITHOUT raising — the gate verdict is untouched.
    for bad in ("no frontmatter", None, 123):
        res = maybe_warn_schema(bad, "doc")  # type: ignore[arg-type]
        assert isinstance(res, SchemaValidation)


# --------------------------------------------------------------------------- #
# strip_frontmatter helper parity.
# --------------------------------------------------------------------------- #
def test_strip_frontmatter_parity():
    assert strip_frontmatter("---\na: 1\n---\nbody") == "\nbody"
    # No well-formed block -> unchanged.
    assert strip_frontmatter("no fence") == "no fence"
    assert strip_frontmatter("---\nunterminated") == "---\nunterminated"
    # Never-raise on non-string.
    assert strip_frontmatter(None) is None  # type: ignore[arg-type]
