"""보고서 작성자 전담 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 문법을 위반했다."""


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] = []


def _narrative_schema_path() -> Path:
    return Path(__file__).resolve().parents[2] / "schemas" / "report-narrative-v3.0.schema.json"


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 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)
        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 _append_value(stack: list[_Node], value: str, level: int, number: int) -> None:
    if stack[-1].level != level - 1:
        raise NarrativeContractError(f"line {number}: value is outside its field")
    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)


def _field_key(label: str, node: Any, index: SchemaIndex, path: str) -> str:
    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)"
        raise NarrativeContractError(
            f"owner=report-writer field `{display_path}` is not an allowed unique "
            f"field; allowed at this position: {listing}"
        )
    if len(matches) > 1:
        collisions = ", ".join(f"`{key}`" for key in matches)
        raise NarrativeContractError(
            f"owner=report-writer field `{display_path}` is not an allowed unique "
            f"field; the label matches more than one schema key: {collisions}"
        )
    return matches[0]


def _parse_scalar(values: list[str], node: Any, index: SchemaIndex, path: str) -> 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 as exc:
            raise NarrativeContractError(f"{path}: expected integer") from exc
    if "number" in types:
        try:
            return float(text)
        except ValueError as exc:
            raise NarrativeContractError(f"{path}: expected number") from exc
    return text


def _parse_value(node: _Node, schema_node: Any, index: SchemaIndex, path: str) -> 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)
    if "array" in types or all(child.kind == "item" for child in node.children):
        return _parse_array(node, schema_node, index, path)
    return _parse_object(node, schema_node, index, path)


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


def _parse_object(node: _Node, schema_node: Any, index: SchemaIndex, path: str) -> dict[str, Any]:
    result: dict[str, Any] = {}
    for child in node.children:
        if child.kind != "field":
            raise NarrativeContractError(f"{path}: object requires named fields")
        key = _field_key(child.label, schema_node, index, path)
        child_path = f"{path}.{key}" if path else key
        if child_path in _NESTED_FORBIDDEN:
            raise NarrativeContractError(
                f"owner=report-writer cannot author `{path}.{child.label}`"
            )
        if key in result:
            raise NarrativeContractError(f"duplicate field: {child_path}")
        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
        )
    return result


def parse_narrative(markdown: str, schema: Mapping[str, Any]) -> dict[str, Any]:
    """Markdown을 작성자 소유 자료로 읽고 소유권 표면을 검증한다."""
    root = _parse_tree(markdown)
    index = SchemaIndex(schema)
    result = _parse_object(root, schema, index, "")
    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 = validate(result, _narrative_schema())
    if errors:
        raise NarrativeContractError("; ".join(errors))
    return result
