"""Tests for splitting a config into ablation units.

Parsing decides what can be attributed and what can be deleted, so the tests
here are mostly about the guardrails: what must never be dropped, what must
never be split, and what the tool refuses to attempt at all.
"""

from __future__ import annotations

import pytest

from ablate.config.parse import (
    KEEP_MARKER,
    MAX_SECTIONS,
    MIN_SECTION_TOKENS,
    PREAMBLE_TITLE,
    check_size,
    render_variant,
    split_sections,
    variant_hash,
)
from ablate.errors import AblateError

#: Comfortably over MIN_SECTION_TOKENS, so tests exercise merging deliberately
#: rather than tripping it by accident.
BODY = "Some substantial guidance about how to write code in this repository. " * 3


def _raw(text: str):
    return split_sections(text, merge_small=False)


# --- splitting --------------------------------------------------------------


def test_sections_split_on_headings():
    sections = _raw(f"# One\n\n{BODY}\n\n# Two\n\n{BODY}\n")
    assert [s.title for s in sections] == ["One", "Two"]
    assert [s.id for s in sections] == ["s01", "s02"]


def test_content_before_the_first_heading_becomes_a_preamble():
    sections = _raw(f"{BODY}\n\n# One\n\n{BODY}\n")
    assert sections[0].title == PREAMBLE_TITLE
    assert BODY.strip()[:20] in sections[0].text


def test_headings_inside_code_fences_do_not_split():
    """A `# comment` in a bash example is not a section boundary."""
    text = f"# Real\n\n{BODY}\n\n```bash\n# not a heading\nls -la\n```\n\n# Also real\n\n{BODY}\n"
    assert [s.title for s in _raw(text)] == ["Real", "Also real"]


def test_heading_levels_and_line_ranges_are_recorded():
    sections = _raw(f"# One\n\n{BODY}\n\n### Deep\n\n{BODY}\n")
    assert [s.level for s in sections] == [1, 3]
    assert sections[0].start_line == 1
    assert sections[1].start_line == 5
    assert sections[0].line_range[1] < sections[1].line_range[0]


def test_empty_config_has_no_sections():
    assert split_sections("") == []
    assert split_sections("   \n\n  ") == []


def test_ids_are_contiguous_after_merging():
    """Ids are typed at a shell prompt, so a gap would be a usability bug."""
    text = "".join(f"# S{i}\n\ntiny\n\n" for i in range(6)) + f"# Big\n\n{BODY}\n"
    sections = split_sections(text)
    assert [s.id for s in sections] == [f"s{n + 1:02d}" for n in range(len(sections))]


# --- pinning ----------------------------------------------------------------


def test_keep_marker_pins_a_section():
    text = f"# One\n\n{BODY}\n\n# Legal\n\n{KEEP_MARKER}\n{BODY}\n"
    pinned = {s.title: s.required for s in _raw(text)}
    assert pinned == {"One": False, "Legal": True}


def test_pinned_sections_survive_a_mask_that_drops_them():
    """The escape hatch for licence rules and "never touch prod". A design must
    not be able to produce a variant missing them, even by accident."""
    text = f"# One\n\n{BODY}\n\n# Legal\n\n{KEEP_MARKER}\n{BODY}\n"
    sections = _raw(text)
    rendered = render_variant(sections, [False, False])
    assert "Legal" in rendered
    assert "One" not in rendered


def test_pinned_sections_are_never_merged_away():
    text = f"# Legal\n\n{KEEP_MARKER}\ntiny\n\n# Other\n\n{BODY}\n"
    titles = [s.title for s in split_sections(text)]
    assert "Legal" in titles


def test_a_small_section_does_not_absorb_a_pinned_neighbour():
    text = f"# Tiny\n\nsmall\n\n# Legal\n\n{KEEP_MARKER}\n{BODY}\n"
    sections = split_sections(text)
    legal = [s for s in sections if s.title == "Legal"]
    assert len(legal) == 1
    assert legal[0].required


# --- small-section merging --------------------------------------------------


def test_small_sections_merge_into_their_predecessor():
    """Below the threshold a section cannot carry a measurable effect, and each
    one kept costs a column in the design matrix."""
    text = f"# Big\n\n{BODY}\n\n# Tiny\n\nsmall\n"
    sections = split_sections(text)
    assert len(sections) == 1
    assert sections[0].title == "Big"
    assert sections[0].merged == ("Tiny",)
    assert "small" in sections[0].text


def test_a_small_leading_section_absorbs_its_successor():
    """It has no predecessor to merge into, so it must not survive alone."""
    text = f"# Tiny\n\nsmall\n\n# Big\n\n{BODY}\n"
    sections = split_sections(text)
    assert len(sections) == 1
    assert sections[0].merged == ("Tiny",)
    assert sections[0].start_line == 1


def test_merging_is_optional():
    text = f"# Big\n\n{BODY}\n\n# Tiny\n\nsmall\n"
    assert len(split_sections(text, merge_small=False)) == 2


def test_merged_section_spans_both_line_ranges():
    text = f"# Big\n\n{BODY}\n\n# Tiny\n\nsmall\n"
    merged = split_sections(text)[0]
    assert merged.line_range[1] >= len(text.splitlines()) - 1


def test_large_sections_are_left_alone():
    text = f"# One\n\n{BODY}\n\n# Two\n\n{BODY}\n"
    sections = split_sections(text)
    assert len(sections) == 2
    assert all(s.merged == () for s in sections)
    assert all(s.tokens >= MIN_SECTION_TOKENS for s in sections)


# --- bullet granularity -----------------------------------------------------


def test_bullet_granularity_splits_top_level_items():
    text = (
        "# Conventions\n\nIntro prose that belongs to the heading.\n\n"
        "- First rule about naming things clearly\n"
        "- Second rule about testing things properly\n"
        "- Third rule about documenting things briefly\n"
    )
    titles = [s.title for s in split_sections(text, granularity="bullet", merge_small=False)]
    assert len(titles) == 4  # intro prose + three bullets
    assert titles[0] == "Conventions"
    assert all(t.startswith("Conventions › ") for t in titles[1:])


def test_nested_bullets_stay_with_their_parent():
    text = (
        "# Conventions\n\n"
        "- Top level rule\n"
        "  - nested detail\n"
        "  - another nested detail\n"
        "- Second top level rule\n"
    )
    sections = split_sections(text, granularity="bullet", merge_small=False)
    # heading line, then one unit per top-level bullet
    assert len(sections) == 3
    assert "nested detail" in sections[1].text
    assert "another nested detail" in sections[1].text
    assert "nested" not in sections[2].text


def test_bullets_inside_code_fences_are_not_split():
    text = "# Conventions\n\n```\n- not a bullet\n- also not\n```\n\n- real bullet\n"
    sections = split_sections(text, granularity="bullet", merge_small=False)
    assert len(sections) == 2  # the fenced block, then the real bullet


def test_a_section_with_no_bullets_is_untouched_at_bullet_granularity():
    text = f"# Conventions\n\n{BODY}\n"
    assert len(split_sections(text, granularity="bullet")) == 1


def test_unknown_granularity_is_rejected():
    with pytest.raises(AblateError, match="granularity"):
        split_sections("# One\n\nbody\n", granularity="paragraph")


# --- size guardrail ---------------------------------------------------------


def test_check_size_accepts_a_normal_config():
    check_size(_raw("".join(f"# S{i}\n\n{BODY}\n\n" for i in range(10))))


def test_check_size_refuses_an_unattributable_config():
    too_many = _raw("".join(f"# S{i}\n\n{BODY}\n\n" for i in range(MAX_SECTIONS + 1)))
    with pytest.raises(AblateError, match="too many"):
        check_size(too_many)


def test_size_refusal_suggests_a_way_forward():
    too_many = _raw("".join(f"# S{i}\n\n{BODY}\n\n" for i in range(MAX_SECTIONS + 1)))
    with pytest.raises(AblateError, match="granularity section"):
        check_size(too_many)


# --- rendering --------------------------------------------------------------


def test_render_keeps_only_the_masked_in_sections():
    sections = _raw(f"# One\n\n{BODY}\n\n# Two\n\n{BODY}\n")
    rendered = render_variant(sections, [True, False])
    assert "# One" in rendered
    assert "# Two" not in rendered


def test_render_all_dropped_is_the_empty_config_floor():
    sections = _raw(f"# One\n\n{BODY}\n\n# Two\n\n{BODY}\n")
    assert render_variant(sections, [False, False]) == ""


def test_render_all_kept_reproduces_every_section():
    sections = _raw(f"# One\n\n{BODY}\n\n# Two\n\n{BODY}\n")
    rendered = render_variant(sections, [True, True])
    assert "# One" in rendered and "# Two" in rendered


def test_render_rejects_a_mask_of_the_wrong_length():
    sections = _raw(f"# One\n\n{BODY}\n")
    with pytest.raises(AblateError, match="mask"):
        render_variant(sections, [True, True])


def test_variant_hash_follows_the_rendered_bytes():
    sections = _raw(f"# One\n\n{BODY}\n\n# Two\n\n{BODY}\n")
    a = variant_hash(render_variant(sections, [True, False]))
    b = variant_hash(render_variant(sections, [True, False]))
    c = variant_hash(render_variant(sections, [False, True]))
    assert a == b
    assert a != c
