"""sections — a note's markdown STRUCTURE: typed blocks and heading sections.

The parsing primitive two features needed and neither had: passage retrieval
(:mod:`vault_core.query.passages`) wants boundaries it can cut a snippet at,
and the heading-targeted edit (``note_section_patch``) wants to name a section
without reproducing its text. Both used to be impossible to do well, so both
were done by hand: `find` returned one grepped line, and the only heading writer
in the tree was ``_iv_section`` in ``mcp/tools.py`` — private to the interview,
matching a heading by exact string equality including its ``##``.

**Line numbers are 1-indexed, inclusive, and count from the first byte of the
file — frontmatter included.** That is the whole point of this module: every
number it returns can be handed straight to ``read_file(start=, end=)`` or
``fs.read_text``. The implementation this was borrowed from
(obsidian-semantic-mcp's ``semantic-chunk-index.ts``) reconstructed the document
by joining its chunks before counting lines, so its offsets drifted from the
real file by however much whitespace it had dropped — the numbers looked right
and addressed the wrong lines. Here the parser only ever indexes ``splitlines()``
of the original text.

Deliberately NOT here: size-based splitting. The upstream version broke a
paragraph at 500 chars so its chunks fit a retrieval budget, which makes the
parse a function of the budget. This module reports the structure the author
wrote; :mod:`vault_core.query.passages` is where a width budget belongs.

Scope: ATX headings (``## Foo``), the shape Obsidian writes. Setext underlines
(``Foo\\n===``) are not headings here.
"""
import re

# ATX heading: up to 3 leading spaces (4 makes it an indented code block), 1-6
# '#', then a space. Trailing '#'s are decoration and get stripped.
_HEADING = re.compile(r"^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$")
_FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
_LIST = re.compile(r"^ {0,3}([-*+]|\d{1,9}[.)])\s+")
_QUOTE = re.compile(r"^ {0,3}>")
_TABLE_DELIM = re.compile(r"^ {0,3}\|?[\s:|-]*-[\s:|-]*\|?\s*$")

HEADING_SEP = "::"   # the heading-path separator: "Areas::Decisions"


def _fence_spans(lines):
    """Line indexes covered by fenced code blocks → ``{i: (start, end)}``.

    Fence tracking is the reason this module exists rather than a regex: a
    ``#`` inside a code block is not a heading, and a ``- `` inside one is not a
    list item. A fence closes only on the same marker character, at least as
    long as the opener, with no info string (CommonMark) — so a ```` ``` ````
    quoted inside a ```` ```` ```` block does not end it.
    """
    spans, open_at, marker = {}, None, None
    for i, line in enumerate(lines):
        m = _FENCE.match(line)
        if not m:
            continue
        run, info = m.group(1), m.group(2).strip()
        if open_at is None:
            open_at, marker = i, run
        elif run[0] == marker[0] and len(run) >= len(marker) and not info:
            for j in range(open_at, i + 1):
                spans[j] = (open_at, i)
            open_at, marker = None, None
    if open_at is not None:                 # unterminated fence: runs to EOF
        for j in range(open_at, len(lines)):
            spans[j] = (open_at, len(lines) - 1)
    return spans


def _frontmatter_end(lines):
    """Index of the closing ``---`` of frontmatter, or -1. Mirrors
    :func:`wikilink.split_frontmatter` — unterminated frontmatter is body."""
    if not lines or lines[0].strip() != "---":
        return -1
    for i in range(1, len(lines)):
        if lines[i].strip() == "---":
            return i
    return -1


def _kind(line, in_table=False):
    """One line's type. ``in_table`` is the caller's lookahead result — a table
    is only a table once its delimiter row has been seen, so a lone pipe-bearing
    line stays prose rather than typing itself (and then absorbing the line after
    it) on the strength of one ``|``."""
    if _HEADING.match(line):
        return "heading"
    if _QUOTE.match(line):
        return "quote"
    if _LIST.match(line):
        return "list"
    if in_table and "|" in line:
        return "table"
    return "paragraph"


def blocks(text):
    """A note → its typed blocks, in document order.

    Each block is ``{kind, text, line_start, line_end}`` where ``kind`` is one of
    ``frontmatter``, ``heading``, ``code``, ``list``, ``quote``, ``table``,
    ``paragraph``. Blank lines separate blocks, except inside a fence, a list, or
    a table, where they belong to the run.
    """
    lines = text.splitlines()
    if not lines:
        return []
    fences = _fence_spans(lines)
    fm_end = _frontmatter_end(lines)

    out, i = [], 0
    if fm_end > 0:
        out.append({"kind": "frontmatter", "text": "\n".join(lines[:fm_end + 1]),
                    "line_start": 1, "line_end": fm_end + 1})
        i = fm_end + 1

    while i < len(lines):
        line = lines[i]
        if i in fences:                              # whole fenced block, verbatim
            start, end = fences[i]
            out.append({"kind": "code", "text": "\n".join(lines[start:end + 1]),
                        "line_start": start + 1, "line_end": end + 1})
            i = end + 1
            continue
        if not line.strip():
            i += 1
            continue
        if _HEADING.match(line):                     # a heading is its own block
            out.append({"kind": "heading", "text": line.strip(),
                        "line_start": i + 1, "line_end": i + 1})
            i += 1
            continue

        # A run of one kind. Lists and tables absorb blank lines and indented
        # continuations (a wrapped bullet is part of its bullet, not a new
        # paragraph); paragraphs and quotes end at the first blank line.
        in_table = "|" in line and (i + 1 < len(lines)
                                    and _TABLE_DELIM.match(lines[i + 1] or ""))
        kind = _kind(line, in_table)
        start, j, last = i, i, i
        while j < len(lines):
            cur = lines[j]
            if j in fences or _HEADING.match(cur):
                break
            if not cur.strip():
                if kind in ("list", "table"):        # blank may be interior…
                    nxt = next((k for k in range(j + 1, len(lines))
                                if lines[k].strip()), None)
                    if nxt is not None and nxt not in fences and (
                            _kind(lines[nxt], kind == "table") == kind
                            or lines[nxt].startswith(("  ", "\t"))):
                        j = nxt
                        continue
                break                                # …otherwise it ends the run
            if j > start and kind != "paragraph" and _kind(cur, kind == "table") != kind \
                    and not cur.startswith(("  ", "\t")):
                break
            if j > start and kind == "paragraph" and _kind(cur, False) != "paragraph":
                break
            last = j
            j += 1
        out.append({"kind": kind, "text": "\n".join(lines[start:last + 1]),
                    "line_start": start + 1, "line_end": last + 1})
        i = last + 1
    return out


def sections(text):
    """A note → its heading sections, in document order.

    Each is ``{heading, level, path, line_start, line_end, body_start, body_end,
    text}``:

    - ``heading`` the heading text without its ``#``s; ``level`` its depth.
    - ``path`` the ancestor chain joined by ``::`` (``"Meeting::Decisions"``) —
      the address ``find_section`` takes and ``note_section_patch`` exposes.
    - ``line_start``/``line_end`` span the heading line through the last line
      before the next heading of the same or shallower level.
    - ``body_start``/``body_end`` span the body alone (the section minus its
      heading line). An empty section has ``body_start > body_end``.

    Content before the first heading (and frontmatter) belongs to no section and
    is not returned; ``blocks`` sees it.
    """
    lines = text.splitlines()
    heads = []
    fences = _fence_spans(lines)
    for i, line in enumerate(lines):
        if i in fences:
            continue
        m = _HEADING.match(line)
        if m:
            heads.append((i, len(m.group(1)), m.group(2).strip()))

    out, stack = [], []          # stack: the open ancestor chain
    for n, (i, level, title) in enumerate(heads):
        while stack and stack[-1][0] >= level:
            stack.pop()
        path = HEADING_SEP.join([t for _l, t in stack] + [title])
        stack.append((level, title))
        end = len(lines)         # to EOF unless a same-or-shallower heading follows
        for j, lv, _t in heads[n + 1:]:
            if lv <= level:
                end = j
                break
        out.append({"heading": title, "level": level, "path": path,
                    "line_start": i + 1, "line_end": end,
                    "body_start": i + 2, "body_end": end,
                    "text": "\n".join(lines[i:end])})
    return out


class SectionNotFound(ValueError):
    """No section matched the heading path (and the caller didn't ask to create
    one). Carries ``.available`` — the note's heading paths — because the useful
    reply to "no such heading" is the list of headings there are."""

    def __init__(self, message, available):
        super().__init__(message)
        self.available = available


class AmbiguousSection(ValueError):
    """A heading path matched more than one section — the caller must qualify it.
    Carries ``.matches`` (the competing paths) so the error can name them."""

    def __init__(self, message, matches):
        super().__init__(message)
        self.matches = matches


def find_section(text, heading_path):
    """Locate one section by heading path, or ``None``.

    Matching is forgiving in the ways that cost a retry and strict in the way
    that costs a wrong edit: leading ``#``s are accepted and ignored, comparison
    is case-insensitive, and the path may be a SUFFIX of the section's ancestor
    chain (``"Decisions"`` finds ``"Meeting::Decisions"``). But a path matching
    two sections raises :class:`AmbiguousSection` rather than picking one —
    same house rule as ``note_patch`` and ``todo_check``.
    """
    want = [p.strip().lstrip("#").strip().casefold()
            for p in str(heading_path or "").split(HEADING_SEP)]
    want = [p for p in want if p]
    if not want:
        return None
    hits = []
    for sec in sections(text):
        chain = [c.casefold() for c in sec["path"].split(HEADING_SEP)]
        if chain[-len(want):] == want:
            hits.append(sec)
    if not hits:
        return None
    if len(hits) > 1:
        raise AmbiguousSection(
            f"{heading_path!r} matches {len(hits)} sections",
            [h["path"] for h in hits])
    return hits[0]


def patch(text, heading_path, new_text, op="append", create_missing=False,
          level=2):
    """Edit ONE heading section's body → ``(new_text_of_note, section_path)``.

    The contract is borrowed from the Local REST API plugin's PATCH headers
    (``Operation`` × ``Target-Type: heading`` × ``Target: A::B``), which is the
    right shape for the same reason it was there: an edit addressed by *where it
    goes* survives the section's text changing under it, where an edit addressed
    by the text it replaces does not. ``note_patch`` — match one exact literal —
    is the precise tool and the brittle one; this is the durable one.

    ``op`` is ``append`` (after the section's last non-blank line), ``prepend``
    (immediately under the heading), or ``replace`` (the body, never the heading
    itself). Nothing outside ``heading_path``'s own section is touched, and a
    nested section stays inside its parent's span — appending to ``Meeting``
    when it has a ``## Decisions`` child appends at the END of Meeting, i.e.
    after Decisions, because that is what Meeting's body is.

    New content is inserted with no blank line of its own, so appending
    ``- item`` to a list continues the list. Section spacing is normalised to
    one blank line under the heading and one before whatever follows —
    the same shape ``_iv_section`` has always written.

    ``create_missing`` appends a fresh ``level``-deep section at end of note
    when the path doesn't match. It creates the LEAF only: it will not build an
    absent ancestor chain, so ``create_missing`` with ``"A::B"`` and no ``A``
    yields a top-level ``B``.
    """
    if op not in ("append", "prepend", "replace"):
        raise ValueError(f"op must be append/prepend/replace, got {op!r}")
    lines = text.splitlines()
    sec = find_section(text, heading_path)          # may raise AmbiguousSection
    add = str(new_text or "").strip("\n").splitlines()

    if sec is None:
        if not create_missing:
            raise SectionNotFound(
                f"no section {heading_path!r} in this note",
                [s["path"] for s in sections(text)])
        title = str(heading_path).split(HEADING_SEP)[-1].strip().lstrip("#").strip()
        out = list(lines)
        while out and not out[-1].strip():
            out.pop()
        head = "#" * max(1, min(6, int(level))) + " " + title
        out += ([""] if out else []) + [head, ""] + add
        return "\n".join(out).rstrip("\n") + "\n", title

    head_i = sec["line_start"] - 1                  # 0-based heading line
    b0, b1 = sec["body_start"] - 1, sec["body_end"]  # 0-based, [b0, b1)
    body = lines[b0:b1] if b1 > b0 else []
    lead, tail = 0, len(body)
    while lead < tail and not body[lead].strip():   # strip the framing blanks;
        lead += 1                                   # they are re-added below
    while tail > lead and not body[tail - 1].strip():
        tail -= 1
    core = body[lead:tail]

    if op == "append":
        core = core + add
    elif op == "prepend":
        core = add + core
    else:
        core = add

    rest = lines[b1:]
    if core:
        out = lines[:head_i + 1] + [""] + core + ([""] if rest else []) + rest
    else:
        out = lines[:head_i + 1] + ([""] if rest else []) + rest
    return "\n".join(out).rstrip("\n") + "\n", sec["path"]
