"""보고서 작성자 전담 Markdown 서사의 손실 없는 읽기·쓰기 계약."""
from __future__ import annotations

import json
import re
from copy import deepcopy
from pathlib import Path
from typing import Any, Mapping

from .final_report_schema import validate
from .report_markdown import SchemaIndex, humanise, label_key
from .json_boundary import load_owned_object


TITLE = "# OKSTRA Report Narrative"
EMPTY_MARKER = "_none_"
_FIELD_RE = re.compile(r"^(?P<indent> *)- \*\*(?P<label>.+)\*\*$")
_ITEM_RE = re.compile(r"^(?P<indent> *)- Item (?P<position>[1-9]\d*)$")
_VALUE_RE = re.compile(r"^(?P<indent> *)> ?(?P<value>.*)$")
_NESTED_FORBIDDEN = frozenset(
    {
        "implementationPlanning.designPreparation",
        "implementationPlanning.planBodyVerification",
    }
)


class NarrativeContractError(ValueError):
    """서사 입력이 보고서 작성자 소유권이나 Markdown 문법을 위반했다."""


# 작성자에게 도달해야 하는 줄 문법 — 합성 패킷의 Authoring Contract 가 이것을
# 그대로 싣는다. 문법이 preamble 템플릿에만 있던 동안 작성자는 read-scope
# 규칙대로 패킷만 읽고 frontmatter + 헤딩으로 된 보통 보고서를 냈다(실측
# 2026-09-09, jobs implementation stage-2: `# OKSTRA Report Narrative` 0회,
# 조립 거부, 최종 리포트 미발행).
NARRATIVE_GRAMMAR_INSTRUCTIONS: tuple[str, ...] = (
    f"Narrative line grammar: the file starts with the line `{TITLE}` and then "
    "contains only three line shapes — `- **Humanised Field Name**` (one field; "
    "nest a child by indenting two more spaces), `- Item <N>` (one array entry, "
    "numbered 1..N without gaps), and `> value` (one scalar; repeat the line for a "
    "multi-line value; `> _none_` for null, an empty object, or an empty array). "
    "A `> value` line is indented exactly two spaces deeper than the `- **Label**` "
    "or `- Item N` line it belongs to — a label at column 0 takes its value at "
    "column 2, a label at column 2 takes it at column 4; a value at column 0 is "
    "outside every field and the file is refused. Blank lines are ignored.",
    "Every other line is rejected — YAML frontmatter (`---` blocks), Markdown "
    "headings (`#`, `##`, `###`), pipe tables at column 0, code fences, bare "
    "paragraphs, JSON. Put such text inside a `> ` value instead. Report assembly "
    "refuses the file otherwise and the run publishes no report.",
)


def narrative_structure_defect(markdown: str) -> str | None:
    """줄 문법 위반 메시지, 없으면 None — 수집 시점의 산출물 검사용.

    값 결함(enum 밖 값 등)은 보지 않는다; 그것은 교정 원장이 고친다. 구조가
    깨진 파일은 원장이 해소될 자료가 없어 재저작 대상이고, 그것을 산출물이
    있는 것으로 세면 결함이 Phase 7 조립까지 숨어 있다 재저작 없이 run 이 닫힌다.
    """
    try:
        _parse_tree(markdown)
    except NarrativeContractError as exc:
        return str(exc)
    return None


class _Node:
    def __init__(self, kind: str, label: str, level: int) -> None:
        self.kind = kind
        self.label = label
        self.level = level
        self.values: list[str] = []
        self.children: list[_Node] = []


_NARRATIVE_SCHEMA_RELATIVE = ("schemas", "report-narrative-v3.0.schema.json")


def _narrative_schema_path() -> Path:
    """schemas 를 실제로 가진 루트에서 해소한다.

    부모 개수를 세면 체크아웃에서만 맞는다 — 설치본은 패키지가
    `~/.okstra/lib/python/` 이고 schemas 는 `~/.okstra/schemas/` 라 한 단계
    어긋나서, 설치본으로 실행할 때 서사 파싱이 스키마 없음으로 죽었다.
    """
    from .paths import find_asset_root

    root = find_asset_root(_NARRATIVE_SCHEMA_RELATIVE)
    if root is None:
        raise NarrativeContractError(
            "could not locate report-narrative-v3.0.schema.json. Set OKSTRA_HOME "
            "or run from a checkout that contains schemas/."
        )
    return root.joinpath(*_NARRATIVE_SCHEMA_RELATIVE)


def _narrative_schema() -> dict[str, Any]:
    return load_owned_object(_narrative_schema_path(), artifact="report narrative schema")


def _allowed_top_level() -> frozenset[str]:
    return frozenset(_narrative_schema().get("properties", {}))


def allowed_top_level_fields() -> frozenset[str]:
    """작성자가 최상위에 쓸 수 있는 필드 이름(서사 스키마의 properties)."""
    return _allowed_top_level()


# 완성 리포트 스키마가 작성자 소유 블록 안에서 required 로 거는 기계 소유 필드.
# `writer_owned_data` 가 떼어내고 `_NESTED_FORBIDDEN` 이 저작을 막는 바로 그
# 이름들이라, 값 제약을 그대로 쓰면 정상 서사가 전부 "필수 필드 없음" 으로
# 깨진다. 앞의 둘은 `_NESTED_FORBIDDEN` 에서 파생시켜 두 목록이 갈라지지
# 않게 한다.
_MACHINE_OWNED_NESTED = frozenset(
    {path.rsplit(".", 1)[-1] for path in _NESTED_FORBIDDEN} | {"designSurfaceCoverage"}
)


def _without_machine_owned_fields(node: Any, *, negated: bool = False) -> Any:
    """기계 소유 필드의 정의와 필수 조건을 뺀 작성자용 스키마 사본.

    `not` 아래는 건드리지 않는다. 거기서 `required: [designPreparation]` 은
    "이 필드가 있으면 안 된다" 는 뜻이고 서사에는 원래 없으니 이미 만족한다.
    이름을 빼면 `not: {required: []}` 가 되는데, 빈 `required` 는 모든 객체가
    만족하므로 그 `not` 은 항상 거짓이 된다 — 통과해야 할 분기를 통과 불가로
    뒤집는다. 실제로 `direction-invalidated` 계획 분기가 그렇게 죽었다.
    """
    if isinstance(node, list):
        return [_without_machine_owned_fields(item, negated=negated) for item in node]
    if not isinstance(node, dict):
        return node
    result: dict[str, Any] = {}
    for key, value in node.items():
        if key == "required" and isinstance(value, list) and not negated:
            result[key] = [
                name for name in value if name not in _MACHINE_OWNED_NESTED
            ]
        elif key == "properties" and isinstance(value, dict) and not negated:
            result[key] = {
                name: _without_machine_owned_fields(field)
                for name, field in value.items() if name not in _MACHINE_OWNED_NESTED
            }
        else:
            result[key] = _without_machine_owned_fields(
                value, negated=negated or key == "not"
            )
    return result


def writer_owned_schema(schema: Mapping[str, Any]) -> dict[str, Any]:
    """작성자가 쓴 서사에 걸 값 제약 — 완성 리포트 스키마에서 잘라 온다.

    `report-narrative-v3.0.schema.json` 은 최상위 **이름** 허용목록이고 값은
    전부 `{"type": "object"}` 스텁이다. 그 스텁으로만 검증하면 enum 밖 값과
    빠진 필수 필드가 서사 단계를 통과하고, Phase 7 조립이 완성 리포트 스키마에
    부딪힐 때에야 거절된다 — 작성기 워커가 이미 끝난 뒤라 되돌릴 방법이 없다.
    실측(2026-08-26, stage 10 run 002): `verdictCard.direction` 에 enum 에 없는
    `proceed` 가, `humanSummary` 에 필수 `whyItMatters` 가 빠진 채로 서사가
    통과했고 `report-finalize` 의 `project-activity` 가 exit 1 로 떨어졌다.

    그래서 이름 소유권은 서사 스키마에서, 값 제약은 넘겨받은 완성 리포트
    스키마에서 가져온다. 두 벌을 만들지 않으므로 드리프트가 생기지 않는다.
    서사는 부분 문서다 — 최상위 `required` 는 싣지 않는다. 이 run 의 task
    블록만 있고 나머지 task-type 블록과 기계 소유 최상위 필드는 없기 때문이다.
    """
    allowed = _allowed_top_level()
    properties = {
        key: value
        for key, value in schema.get("properties", {}).items()
        if key in allowed
    }
    return {
        "type": "object",
        "additionalProperties": False,
        "properties": _without_machine_owned_fields(properties),
        "$defs": _without_machine_owned_fields(schema.get("$defs", {})),
    }


def validate_writer_owned(data: Mapping[str, Any], schema: Mapping[str, Any]) -> list[str]:
    """작성자 소유 값 제약으로 서사 자료를 검증한다 — `parse_narrative` 와 같은 스키마."""
    return validate(dict(data), writer_owned_schema(schema))


def task_narrative_errors(
    data: Mapping[str, Any], schema: Mapping[str, Any], task_type: str,
) -> list[str]:
    """실행 유형에 따른 판정·후속 작업 규칙을 정본 스키마에서 검사한다."""
    context = {**data, "header": {"taskType": task_type}}
    errors: list[str] = []
    for branch in schema.get("allOf", []):
        fields = {
            key: value
            for key, value in branch.get("then", {}).get("properties", {}).items()
            if key in {"finalVerdict", "followUpTasks"}
        }
        if not fields:
            continue
        errors.extend(validate(context, {
            "$defs": schema.get("$defs", {}),
            "if": branch["if"],
            "then": {"properties": fields},
        }))
    return list(dict.fromkeys(errors))


def writer_owned_path_defect(path: str) -> str | None:
    """이 필드 경로에 작성자가 쓸 수 없는 이유, 쓸 수 있으면 None.

    경로는 검증기 문법(`a.b[1].c`)이다. 최상위 이름은 서사 스키마의 허용목록에
    있어야 하고, `_NESTED_FORBIDDEN` 아래는 기계 소유다. 교정 원장이 그 자리를
    고치라고 하면 조립이 나중에 거절할 것이므로 여기서 먼저 거절한다.
    """
    dotted = re.sub(r"\[\d+\]", "", path)
    top = dotted.split(".", 1)[0]
    if top not in _allowed_top_level():
        owned = ", ".join(f"`{humanise(key)}`" for key in sorted(_allowed_top_level()))
        return (
            f"`{top}` is not a writer-owned top-level field; writer-owned: {owned}"
        )
    for forbidden in _NESTED_FORBIDDEN:
        if dotted == forbidden or dotted.startswith(f"{forbidden}."):
            return f"`{forbidden}` is machine-owned; the report writer cannot author it"
    return None


def writer_owned_data(data: Mapping[str, Any]) -> dict[str, Any]:
    """완성 리포트에서 보고서 작성자 소유 필드만 복사한다."""
    owned = {
        key: deepcopy(value)
        for key, value in data.items()
        if key in _allowed_top_level()
    }
    planning = owned.get("implementationPlanning")
    if isinstance(planning, dict):
        planning.pop("designPreparation", None)
        planning.pop("planBodyVerification", None)
        for stage in planning.get("stages", ()):
            if isinstance(stage, dict):
                stage.pop("designSurfaceCoverage", None)
    return owned


def _schema_branches(node: Any, index: SchemaIndex) -> list[dict[str, Any]]:
    resolved = index.resolve(node)
    branches = [resolved]
    for keyword in ("allOf", "oneOf", "anyOf"):
        branches.extend(index.resolve(branch) for branch in resolved.get(keyword, ()))
    return branches


def _node_types(node: Any, index: SchemaIndex) -> set[str]:
    result: set[str] = set()
    for branch in _schema_branches(node, index):
        value = branch.get("type")
        if isinstance(value, str):
            result.add(value)
        elif isinstance(value, list):
            result.update(item for item in value if isinstance(item, str))
        if "properties" in branch:
            result.add("object")
        if "items" in branch:
            result.add("array")
        constant = branch.get("const")
        if isinstance(constant, bool):
            result.add("boolean")
        elif isinstance(constant, int):
            result.add("integer")
        elif isinstance(constant, float):
            result.add("number")
        elif isinstance(constant, str):
            result.add("string")
        for item in branch.get("enum", ()):
            if isinstance(item, bool):
                result.add("boolean")
            elif isinstance(item, int):
                result.add("integer")
            elif isinstance(item, float):
                result.add("number")
            elif isinstance(item, str):
                result.add("string")
    return result


def _scalar_lines(value: Any) -> list[str]:
    if value is None:
        return [EMPTY_MARKER]
    if isinstance(value, bool):
        return [str(value).lower()]
    if isinstance(value, (int, float)):
        return [str(value)]
    return str(value).split("\n")


def _render_value(
    value: Any, node: Any, index: SchemaIndex, level: int,
) -> list[str]:
    prefix = "  " * level
    if isinstance(value, Mapping):
        if not value:
            return [f"{prefix}> {EMPTY_MARKER}"]
        return _render_fields(value, node, index, level)
    if isinstance(value, list):
        if not value:
            return [f"{prefix}> {EMPTY_MARKER}"]
        lines: list[str] = []
        for position, item in enumerate(value, start=1):
            lines.append(f"{prefix}- Item {position}")
            lines.extend(_render_value(item, index.item(node), index, level + 1))
        return lines
    return [f"{prefix}> {line}" for line in _scalar_lines(value)]


def _render_fields(
    value: Mapping[str, Any], node: Any, index: SchemaIndex, level: int,
) -> list[str]:
    order = index.key_order(node)
    keys = [key for key in order if key in value]
    keys.extend(key for key in value if key not in keys)
    lines: list[str] = []
    prefix = "  " * level
    for key in keys:
        lines.append(f"{prefix}- **{humanise(key)}**")
        lines.extend(_render_value(value[key], index.child(node, key), index, level + 1))
    return lines


def render_narrative(data: Mapping[str, Any], schema: Mapping[str, Any]) -> str:
    """작성자 소유 자료를 계층형 Markdown 목록으로 렌더한다."""
    unknown = sorted(set(data) - _allowed_top_level())
    if unknown:
        raise NarrativeContractError(
            f"owner=report-writer cannot author fields: {unknown}"
        )
    index = SchemaIndex(schema)
    lines = [TITLE, ""]
    lines.extend(_render_fields(data, schema, index, 0))
    return "\n".join(lines).rstrip() + "\n"


def _line_level(indent: str, line_number: int) -> int:
    if len(indent) % 2:
        raise NarrativeContractError(
            f"line {line_number}: indentation must use pairs of spaces"
        )
    return len(indent) // 2


def _parse_tree(markdown: str) -> _Node:
    lines = markdown.splitlines()
    if not lines or lines[0].strip() != TITLE:
        raise NarrativeContractError(f"narrative must start with `{TITLE}`")
    root = _Node("root", "", -1)
    stack = [root]
    for number, line in enumerate(lines[1:], start=2):
        if not line.strip():
            continue
        field = _FIELD_RE.match(line)
        item = _ITEM_RE.match(line)
        value = _VALUE_RE.match(line)
        if field or item:
            match = field or item
            level = _line_level(match.group("indent"), number)
            _append_node(stack, field, item, level, number)
        elif value:
            level = _line_level(value.group("indent"), number)
            _append_value(stack, value.group("value"), level, number, lines)
        else:
            raise NarrativeContractError(
                f"line {number}: unsupported Markdown syntax `{line.strip()}` — "
                "a narrative line is only `- **Field Name**`, `- Item <N>`, or "
                "`> value`. Headings, tables at column 0, code fences, bare "
                "paragraphs, JSON and YAML are rejected; put such text inside a "
                "`> ` value line instead"
            )
    return root


def _append_node(stack: list[_Node], field: Any, item: Any, level: int, number: int) -> None:
    while stack[-1].level >= level:
        stack.pop()
    if stack[-1].level != level - 1:
        raise NarrativeContractError(f"line {number}: skipped a nesting level")
    node = _Node(
        "field" if field else "item",
        field.group("label") if field else item.group("position"),
        level,
    )
    stack[-1].children.append(node)
    stack.append(node)


def _misplaced_value_lines(lines: list[str]) -> int:
    """자기 라벨보다 두 칸 더 들여쓰지 않은 `> value` 줄의 수.

    파서는 첫 결함에서 멈추므로 규모를 말하지 못한다 — 129줄이 전부 0열에
    붙은 서술문이 `line 5` 한 건으로 보고됐다(2026-09-02 실측). 여기서는
    직전 라벨(`- **…**` / `- Item N`)의 들여쓰기와 견줘 세기만 한다; 판정은
    여전히 파서의 것이다.
    """
    misplaced = 0
    label_indent = None
    for line in lines[1:]:
        if not line.strip():
            continue
        match = _FIELD_RE.match(line) or _ITEM_RE.match(line)
        if match:
            label_indent = len(match.group("indent"))
            continue
        value = _VALUE_RE.match(line)
        if value and label_indent is not None:
            if len(value.group("indent")) != label_indent + 2:
                misplaced += 1
    return misplaced


def _append_value(
    stack: list[_Node], value: str, level: int, number: int, lines: list[str] | None = None,
) -> None:
    if stack[-1].level != level - 1:
        count = _misplaced_value_lines(lines) if lines is not None else 0
        scale = (
            f"; {count} value line(s) in this file share the defect"
            if count > 1
            else ""
        )
        raise NarrativeContractError(
            f"line {number}: value is outside its field — a `> value` line is "
            "indented exactly two spaces deeper than the `- **Label**` or "
            f"`- Item N` line it belongs to{scale}"
        )
    if stack[-1].children:
        raise NarrativeContractError(f"line {number}: field mixes values and children")
    stack[-1].values.append(value)


def _allowed_labels(node: Any, index: SchemaIndex, path: str) -> list[str]:
    """이 위치에서 저작자가 쓸 수 있는 사람이 읽는 필드 표기."""
    keys = index.key_order(node)
    if not path:
        keys = [key for key in keys if key in _allowed_top_level()]
    # 파서가 뒤에서 거부할 필드를 여기서 권하면 안내가 다음 거부를 만든다.
    keys = [key for key in keys if f"{path}.{key}".lstrip(".") not in _NESTED_FORBIDDEN]
    return sorted(humanise(key) for key in keys)


class _Defects:
    """값 단계에서 모은 결함. 첫 건에서 멈추지 않고 문서 전체를 읽는다.

    파서가 첫 강제 변환 실패에서 예외를 내던 동안, 같은 종류의 결함이 여러
    자리에 있어도 한 자리만 보고돼 작성자가 같은 문서를 회차마다 한 자리씩
    고쳤다(2026-09-03 실측, dev-10626 implementation-option-selection: 소수
    `coveragePercent` 가 랭킹 옵션 둘에 있는데 `rankedOptions[1]` 한 건만).
    스키마 검증(`validate`)은 이미 전건을 모으므로 값 단계도 그렇게 한다.
    """

    def __init__(self) -> None:
        self.messages: list[str] = []
        self.paths: list[str] = []

    def add(self, path: str, message: str) -> None:
        self.paths.append(path)
        self.messages.append(message)

    def covers(self, error: str) -> bool:
        """스키마 검증이 같은 자리(또는 그 아래)에 낸 오류인가 — 값 단계가 이미
        보고한 자리를 두 번 말하지 않는다."""
        location = error.split(": ", 1)[0]
        return any(
            location == path or location.startswith(f"{path}.") or location.startswith(f"{path}[")
            for path in self.paths
        )


def _field_key(
    label: str, node: Any, index: SchemaIndex, path: str, defects: _Defects,
) -> str | None:
    candidates: dict[str, list[str]] = {}
    for key in index.key_order(node):
        candidates.setdefault(label_key(humanise(key)), []).append(key)
    matches = candidates.get(label_key(label), [])
    if not matches:
        matches = index.keys_for_label(label)
    display_path = f"{path}.{label}" if path else label
    # 거부만 알리면 다음 시도도 추측이 된다. 이 위치에서 허용되는 표기를
    # 함께 실어, 재작성이 목록 대조로 끝나게 한다.
    if not matches:
        allowed = _allowed_labels(node, index, path)
        listing = ", ".join(f"`{item}`" for item in allowed) or "(none)"
        defects.add(
            display_path,
            f"owner=report-writer field `{display_path}` is not an allowed unique "
            f"field; allowed at this position: {listing}",
        )
        return None
    if len(matches) > 1:
        collisions = ", ".join(f"`{key}`" for key in matches)
        defects.add(
            display_path,
            f"owner=report-writer field `{display_path}` is not an allowed unique "
            f"field; the label matches more than one schema key: {collisions}",
        )
        return None
    return matches[0]


def _parse_scalar(
    values: list[str], node: Any, index: SchemaIndex, path: str, defects: _Defects,
) -> Any:
    types = _node_types(node, index)
    text = "\n".join(values)
    if text == EMPTY_MARKER and "null" in types:
        return None
    if "boolean" in types and text in {"true", "false"}:
        return text == "true"
    if "integer" in types:
        try:
            return int(text)
        except ValueError:
            defects.add(path, f"{path}: expected integer")
            return text
    if "number" in types:
        try:
            return float(text)
        except ValueError:
            defects.add(path, f"{path}: expected number")
            return text
    return text


def _parse_value(
    node: _Node, schema_node: Any, index: SchemaIndex, path: str, defects: _Defects,
) -> Any:
    types = _node_types(schema_node, index)
    if node.values:
        if node.values == [EMPTY_MARKER] and "null" in types:
            return None
        if node.values == [EMPTY_MARKER] and "object" in types:
            return {}
        if node.values == [EMPTY_MARKER] and "array" in types:
            return []
        return _parse_scalar(node.values, schema_node, index, path, defects)
    if "array" in types or all(child.kind == "item" for child in node.children):
        return _parse_array(node, schema_node, index, path, defects)
    return _parse_object(node, schema_node, index, path, defects)


def _parse_array(
    node: _Node, schema_node: Any, index: SchemaIndex, path: str, defects: _Defects,
) -> list[Any]:
    if any(child.kind != "item" for child in node.children):
        defects.add(path, f"{path}: array requires Item rows")
        return []
    positions = [int(child.label) for child in node.children]
    if positions != list(range(1, len(positions) + 1)):
        defects.add(path, f"{path}: Item numbers must be 1..N")
        return []
    item_schema = index.item(schema_node)
    return [
        _parse_value(child, item_schema, index, f"{path}[{position - 1}]", defects)
        for position, child in zip(positions, node.children, strict=True)
    ]


def _parse_object(
    node: _Node, schema_node: Any, index: SchemaIndex, path: str, defects: _Defects,
) -> dict[str, Any]:
    result: dict[str, Any] = {}
    for child in node.children:
        if child.kind != "field":
            defects.add(path, f"{path}: object requires named fields")
            return result
        key = _field_key(child.label, schema_node, index, path, defects)
        if key is None:
            continue
        child_path = f"{path}.{key}" if path else key
        if child_path in _NESTED_FORBIDDEN:
            defects.add(
                child_path, f"owner=report-writer cannot author `{path}.{child.label}`"
            )
            continue
        if key in result:
            defects.add(child_path, f"duplicate field: {child_path}")
            continue
        child_schema = index.child(schema_node, key)
        if not _node_types(child_schema, index):
            child_schema = index.schema_for_key(key)
        result[key] = _parse_value(
            child, child_schema, index, child_path, defects
        )
    return result


def parse_narrative(markdown: str, schema: Mapping[str, Any]) -> dict[str, Any]:
    """Markdown을 작성자 소유 자료로 읽고, 소유권 표면과 값 제약을 검증한다.

    `schema` 는 완성 리포트 스키마다. 이름·타입 해석과 값 검증이 모두 그
    한 벌에서 나온다(`writer_owned_schema`). 값 단계의 결함(강제 변환·모양·
    허용되지 않는 필드)과 스키마 검증의 결함을 한 예외에 모두 싣는다 — 값
    단계가 보고한 자리는 스키마 검증에서 다시 말하지 않는다.
    """
    result, defects = parse_narrative_structure(markdown, schema)
    if defects:
        raise NarrativeContractError("; ".join(defects))
    return result


def parse_narrative_structure(
    markdown: str, schema: Mapping[str, Any],
) -> tuple[dict[str, Any], list[str]]:
    """구조는 읽고 값 결함은 돌려준다 — 교정 원장의 기준 서사를 읽는 입구.

    줄 문법과 소유권(허용되지 않는 최상위 이름)은 여기서도 예외다: 그런
    문서는 원장의 경로가 해소될 자료가 없어 재저작 대상이다. 값 결함(패턴
    밖 id, enum 밖 값, 빠진 필수 필드)은 예외가 아니라 목록이다 — 그것이
    바로 원장이 고치려는 자리이고, 실측(2026-09-04, dev-10626 a3 서사: `SC-`
    id 20곳)에서 `parse_narrative` 로 읽으면 기준 서사 전체가 거절돼 원장을
    쓸 수 있는 교정이 한 건도 없었다.
    """
    root = _parse_tree(markdown)
    index = SchemaIndex(schema)
    defects = _Defects()
    result = _parse_object(root, schema, index, "", defects)
    unknown = sorted(set(result) - _allowed_top_level())
    if unknown:
        labels = [humanise(key) for key in unknown]
        owned = ", ".join(
            f"`{humanise(key)}`" for key in sorted(_allowed_top_level())
        )
        raise NarrativeContractError(
            f"owner=report-writer cannot author fields: {labels}; "
            f"writer-owned top-level fields: {owned}"
        )
    errors = [
        error
        for error in validate(result, writer_owned_schema(schema))
        if not defects.covers(error)
    ]
    return result, defects.messages + errors
