"""Trailer rendering and parsing (SPEC §7.5).

The trailer module is the engine's structured-metadata channel — the
shape of the round-trip matters more than any one helper.
"""

import pytest

from attractor.checkpoint import (
    CheckpointTrailers,
    Trailer,
    TrailerError,
    append_trailer,
    format_message,
    iter_trailers,
    parse_trailer,
)


# [unit->REQ-STORE-TRAILERS]
class TestTrailerDataclass:
    """`Trailer(key, value)` validates its inputs at construction."""

    def test_basic_construction(self) -> None:
        t = Trailer(key="Run-Id", value="abc-123")
        assert t.render() == "Run-Id: abc-123"

    def test_empty_key_rejected(self) -> None:
        with pytest.raises(TrailerError, match="non-empty"):
            Trailer(key="", value="x")

    def test_empty_value_rejected(self) -> None:
        with pytest.raises(TrailerError, match="empty value"):
            Trailer(key="Run-Id", value="")

    def test_colon_in_key_rejected(self) -> None:
        with pytest.raises(TrailerError, match="illegal"):
            Trailer(key="Run:Id", value="abc")

    def test_newline_in_value_rejected(self) -> None:
        # Multiline values would terminate the trailer paragraph and
        # produce parse drift downstream.
        with pytest.raises(TrailerError, match="multi"):
            Trailer(key="Run-Id", value="a\nb")

    def test_space_in_key_rejected(self) -> None:
        with pytest.raises(TrailerError, match="illegal"):
            Trailer(key="Run Id", value="abc")


# [unit->REQ-STORE-TRAILERS]
class TestCheckpointTrailersRender:
    """Canonical ordering and "only non-None fields" rules."""

    def test_render_full_set_in_canonical_order(self) -> None:
        # The order matches SPEC §7.5's example block exactly.
        full = CheckpointTrailers(
            run_id="r1",
            stage="plan",
            node="approve",
            outcome="SUCCESS",
            duration_ms=42,
            tokens_in=10,
            tokens_out=20,
        )
        rendered = [t.render() for t in full.render()]
        assert rendered == [
            "Run-Id: r1",
            "Stage: plan",
            "Node: approve",
            "Outcome: SUCCESS",
            "Duration-Ms: 42",
            "Tokens-In: 10",
            "Tokens-Out: 20",
        ]

    def test_render_skips_none_fields(self) -> None:
        # The common worktree-commit case carries just run_id + node.
        slim = CheckpointTrailers(run_id="r1", node="approve")
        rendered = [t.render() for t in slim.render()]
        assert rendered == ["Run-Id: r1", "Node: approve"]

    def test_render_empty_when_all_none(self) -> None:
        assert CheckpointTrailers().render() == []


# [unit->REQ-STORE-TRAILERS]
class TestCheckpointTrailersRoundTrip:
    """A rendered trailer block must parse back to the original record."""

    def test_round_trip_preserves_all_fields(self) -> None:
        original = CheckpointTrailers(
            run_id="r1",
            stage="plan",
            node="approve",
            outcome="SUCCESS",
            duration_ms=42,
            tokens_in=10,
            tokens_out=20,
        )
        msg = format_message("subject", "body line", original.render())
        parsed = CheckpointTrailers.parse_from(msg)
        assert parsed == original

    def test_round_trip_partial(self) -> None:
        original = CheckpointTrailers(run_id="r1", node="approve")
        msg = format_message("subject", "", original.render())
        parsed = CheckpointTrailers.parse_from(msg)
        assert parsed == original

    def test_parse_ignores_unknown_keys(self) -> None:
        msg = format_message(
            "subject",
            "",
            [
                Trailer(key="Run-Id", value="r1"),
                Trailer(key="Co-Authored-By", value="Attractor <a@b>"),
            ],
        )
        parsed = CheckpointTrailers.parse_from(msg)
        assert parsed.run_id == "r1"

    def test_parse_case_insensitive(self) -> None:
        # Real-world `git log` output sometimes lowercases trailer
        # keys; the parse path must still see them.
        msg = "subj\n\nrun-id: r1\nnode: approve\n"
        parsed = CheckpointTrailers.parse_from(msg)
        assert parsed.run_id == "r1"
        assert parsed.node == "approve"

    def test_parse_integer_invalid_raises(self) -> None:
        msg = "subj\n\nDuration-Ms: not-a-number\n"
        with pytest.raises(TrailerError, match="integer"):
            CheckpointTrailers.parse_from(msg)


# [unit->REQ-STORE-TRAILERS]
class TestMessageHelpers:
    """The free-function helpers `format_message`, `append_trailer`, etc."""

    def test_format_message_blank_separators(self) -> None:
        msg = format_message(
            "subject", "body paragraph", [Trailer("Run-Id", "r1")]
        )
        assert msg == "subject\n\nbody paragraph\n\nRun-Id: r1\n"

    def test_format_message_no_body(self) -> None:
        msg = format_message("subject", "", [Trailer("Run-Id", "r1")])
        assert msg == "subject\n\nRun-Id: r1\n"

    def test_format_message_no_trailers(self) -> None:
        msg = format_message("subject", "body", [])
        assert msg == "subject\n\nbody\n"

    def test_append_trailer_to_existing_paragraph(self) -> None:
        original = "subject\n\nbody\n\nRun-Id: r1\n"
        result = append_trailer(original, Trailer("Node", "approve"))
        assert "Run-Id: r1" in result
        assert "Node: approve" in result
        # Body should still be present.
        assert "body" in result

    def test_append_trailer_creates_paragraph(self) -> None:
        original = "subject\n\nbody\n"
        result = append_trailer(original, Trailer("Run-Id", "r1"))
        assert result.endswith("Run-Id: r1\n")

    def test_iter_trailers_walks_paragraph(self) -> None:
        msg = "subj\n\nRun-Id: r1\nNode: approve\n"
        assert list(iter_trailers(msg)) == [("Run-Id", "r1"), ("Node", "approve")]

    def test_iter_trailers_ignores_prose_paragraph(self) -> None:
        # A trailing paragraph that mixes prose with key/value lines
        # is NOT a trailer paragraph (matches `git interpret-trailers`).
        msg = "subj\n\nNote: foo.\nThis is prose, not a trailer.\n"
        assert list(iter_trailers(msg)) == []

    def test_parse_trailer_finds_value(self) -> None:
        msg = "subj\n\nRun-Id: r1\n"
        assert parse_trailer(msg, "Run-Id") == "r1"

    def test_parse_trailer_returns_none_for_missing(self) -> None:
        msg = "subj\n\nRun-Id: r1\n"
        assert parse_trailer(msg, "Outcome") is None
