"""Markdown config → ablation units (docs/07-ablation.md).

Splitting is on ATX headings by default. Content before the first heading
becomes a synthetic preamble section, because a `CLAUDE.md` that opens with
three paragraphs before its first heading is common and that prose is not free.

**Be precise about what this delivers.** The default output is *section-level
attribution with line ranges*, not per-line attribution. Nobody deletes one
line of a bulleted list, so section level is what a user actually wants — but
claiming per-line and delivering per-heading is the kind of small dishonesty a
tool selling statistical rigour cannot afford. `--granularity bullet` makes
line-level literally true, and roughly triples K and therefore the cost, so it
is opt-in and the CLI says so.
"""

from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass, replace

from ablate.config.tokens import estimate_tokens
from ablate.errors import AblateError

_HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
_FENCE = re.compile(r"^\s*```")
_BULLET = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+\S")

#: Escape hatch for content that must survive every variant — licence rules,
#: "never touch prod". A pinned section is never dropped, so it never gets a
#: coefficient either, and the report says why.
KEEP_MARKER = "<!-- ablate:keep -->"

#: Sections below this cannot plausibly carry a measurable effect, and each one
#: kept costs a column in the design matrix. Merged into a neighbour by default.
MIN_SECTION_TOKENS = 20

#: Design cost grows linearly in K and interpretability collapses well before
#: this. Past it, refuse rather than produce an unreadable table.
MAX_SECTIONS = 24

PREAMBLE_TITLE = "(preamble)"


@dataclass(frozen=True)
class Section:
    """One addressable chunk of a config file.

    `id` is a stable short handle (`s01`, `s02`, …) so it can be typed at a
    shell prompt: `ablate compare CLAUDE.md --drop s11,s06`.
    """

    id: str
    title: str
    level: int
    start_line: int
    end_line: int
    text: str
    required: bool = False
    #: Titles this section absorbed by small-section merging, for the report.
    merged: tuple[str, ...] = ()

    @property
    def tokens(self) -> int:
        return estimate_tokens(self.text)

    @property
    def line_count(self) -> int:
        return len(self.text.splitlines())

    @property
    def line_range(self) -> tuple[int, int]:
        return (self.start_line, self.end_line)


def _heading_boundaries(lines: list[str]) -> list[tuple[int, str, int]]:
    """(line index, title, level) for every heading outside a code fence.

    Headings inside fenced code are not headings — a `# comment` line in a bash
    example must not open a new section. Getting this wrong would split configs
    at arbitrary points and misattribute token costs.
    """
    boundaries: list[tuple[int, str, int]] = []
    in_code = False
    for i, line in enumerate(lines):
        if _FENCE.match(line):
            in_code = not in_code
            continue
        if in_code:
            continue
        match = _HEADING.match(line.rstrip("\n"))
        if match:
            boundaries.append((i, match.group(2).strip(), len(match.group(1))))
    return boundaries


def _bullet_boundaries(lines: list[str], start: int, end: int) -> list[int]:
    """Line indices of top-level list items in `lines[start:end]`.

    Only top-level: nested items belong to their parent, and splitting them out
    would produce units too small to mean anything.
    """
    out: list[int] = []
    in_code = False
    base_indent: int | None = None
    for i in range(start, end):
        if _FENCE.match(lines[i]):
            in_code = not in_code
            continue
        if in_code:
            continue
        match = _BULLET.match(lines[i])
        if not match:
            continue
        indent = len(match.group(1).expandtabs(4))
        if base_indent is None:
            base_indent = indent
        if indent <= base_indent:
            out.append(i)
    return out


def split_sections(
    text: str, *, granularity: str = "section", merge_small: bool = True
) -> list[Section]:
    """Split markdown into ablation units.

    `granularity="bullet"` additionally splits top-level list items within each
    section into their own units.
    """
    if granularity not in ("section", "bullet"):
        raise AblateError(f"unknown granularity {granularity!r} — use 'section' or 'bullet'")
    if not text.strip():
        return []

    lines = text.splitlines(keepends=True)
    boundaries = _heading_boundaries(lines)
    if not boundaries or boundaries[0][0] > 0:
        boundaries.insert(0, (0, PREAMBLE_TITLE, 0))

    spans: list[tuple[int, int, str, int]] = []  # start, end, title, level
    for n, (start, title, level) in enumerate(boundaries):
        end = boundaries[n + 1][0] if n + 1 < len(boundaries) else len(lines)
        if granularity == "bullet":
            spans.extend(_split_on_bullets(lines, start, end, title, level))
        else:
            spans.append((start, end, title, level))

    sections = [
        Section(
            id="",  # assigned after merging, so ids stay contiguous
            title=title,
            level=level,
            start_line=start + 1,
            end_line=end,
            text="".join(lines[start:end]),
            required=KEEP_MARKER in "".join(lines[start:end]),
        )
        for start, end, title, level in spans
        if "".join(lines[start:end]).strip()
    ]

    if merge_small:
        sections = _merge_small(sections)
    return _assign_ids(sections)


def _split_on_bullets(
    lines: list[str], start: int, end: int, title: str, level: int
) -> list[tuple[int, int, str, int]]:
    bullets = _bullet_boundaries(lines, start, end)
    if not bullets:
        return [(start, end, title, level)]

    spans = []
    # Whatever precedes the first bullet is the section's own prose and stays
    # with the heading — dropping a heading's intro but keeping its bullets
    # would render a variant that reads as broken markdown.
    if bullets[0] > start:
        spans.append((start, bullets[0], title, level))
    for n, bullet_start in enumerate(bullets):
        bullet_end = bullets[n + 1] if n + 1 < len(bullets) else end
        label = lines[bullet_start].strip()
        label = label[:60].rstrip() + ("…" if len(label) > 60 else "")
        spans.append((bullet_start, bullet_end, f"{title} › {label}", level + 1))
    return spans


def _merge_small(sections: list[Section]) -> list[Section]:
    """Fold sub-threshold sections into a neighbour.

    Merges backward into the preceding section so a heading stays with the
    prose beneath it. Pinned sections are never merged in either direction:
    their whole purpose is to stay individually identifiable and always present.
    """
    if len(sections) <= 1:
        return sections

    out: list[Section] = []
    for section in sections:
        small = section.tokens < MIN_SECTION_TOKENS and not section.required
        if small and out and not out[-1].required:
            previous = out[-1]
            out[-1] = replace(
                previous,
                text=previous.text + section.text,
                end_line=section.end_line,
                merged=(*previous.merged, section.title),
            )
        else:
            out.append(section)

    # A small leading section has no predecessor to merge into, so it absorbs
    # its successor instead rather than surviving as a degenerate unit.
    if len(out) > 1 and out[0].tokens < MIN_SECTION_TOKENS and not out[0].required:
        head, nxt = out[0], out[1]
        if not nxt.required:
            out[1] = replace(
                nxt,
                text=head.text + nxt.text,
                start_line=head.start_line,
                merged=(head.title, *nxt.merged),
            )
            out.pop(0)
    return out


def _assign_ids(sections: list[Section]) -> list[Section]:
    return [replace(s, id=f"s{n + 1:02d}") for n, s in enumerate(sections)]


def check_size(sections: list[Section]) -> None:
    """Refuse designs too large to interpret or afford."""
    if len(sections) > MAX_SECTIONS:
        raise AblateError(
            f"{len(sections)} sections is too many to attribute ({MAX_SECTIONS} max). "
            f"Design cost grows linearly in K and the table stops being readable "
            f"well before this. Run at --granularity section, or split the file."
        )


def render_variant(sections: list[Section], mask: list[bool] | tuple[bool, ...]) -> str:
    """Render the config with only the masked-in sections present.

    Pinned sections are forced in regardless of the mask, so a design can never
    accidentally produce a variant missing legally- or safety-required content.
    """
    if len(mask) != len(sections):
        raise AblateError(f"mask has {len(mask)} entries for {len(sections)} sections")
    kept = [
        s.text.strip() for s, keep in zip(sections, mask, strict=True) if keep or s.required
    ]
    return "\n\n".join(kept) + ("\n" if kept else "")


def variant_hash(text: str) -> str:
    """Identity of a rendered variant — what the trial cache keys on."""
    return "sha256:" + hashlib.sha256(text.encode()).hexdigest()[:16]
