"""verify_plan_completeness — deterministic check on plan/RFC frontmatter (#184).

Per Discussion #182's validation strategy, this catches error class 2
(plan describes work but never starts it) and error class 3 (plan is
incomplete relative to user intent). Consumes the YAML frontmatter
contract from `validation.md` (#183).

Public surface:
- ``verify_plan_completeness(body) -> dict`` — orchestrator.
- ``parse_plan_frontmatter(body) -> dict | None`` — extracts and parses
  the YAML block at the top of a plan body, or None if absent.
- ``extract_ac_ids_from_body(body) -> list[str]`` — pulls every
  ``(ACn)`` checkbox prefix from the markdown body's
  ``## Acceptance criteria`` section.

Returns the report shape documented in #184:

    {
      "valid": bool,
      "errors": [{"field": str, "issue": str}, ...],
      "warnings": [{"field": str, "issue": str}, ...],
    }

The frontmatter parser is purpose-built rather than depending on
PyYAML — the plan-frontmatter shape is constrained (a small set of
top-level keys, scalars / list-of-strings / list-of-{id,description}
dicts), so a focused parser is more robust than tolerating PyYAML's
edge cases on a format we own.
"""
from __future__ import annotations

import re
from pathlib import PurePosixPath


_AC_ID_RE = re.compile(r"^AC\d+$")
_BODY_AC_PREFIX_RE = re.compile(r"\(\s*(AC\d+)\s*\)")


_REQUIRED_FIELDS: tuple[str, ...] = (
    "plan_id",
    "touched_files",
    "acceptance_criteria",
)
_KNOWN_FIELDS: frozenset[str] = frozenset(
    {
        "plan_id",
        "touched_files",
        "entities_introduced",
        "entities_modified",
        "assumptions",
        "acceptance_criteria",
    }
)


class FrontmatterParseError(Exception):
    """Raised when the YAML frontmatter is structurally malformed."""


def parse_plan_frontmatter(body: str) -> dict | None:
    """Extract the YAML frontmatter from a plan body.

    Frontmatter is the block delimited by ``---`` at the very top of
    ``body``. Returns the parsed dict, or ``None`` if no frontmatter
    exists. Raises ``FrontmatterParseError`` on a malformed block (e.g.,
    open delimiter without a close).
    """
    if not body.startswith("---\n"):
        return None
    end = body.find("\n---", 4)
    if end == -1:
        raise FrontmatterParseError(
            "frontmatter opens with `---` but is not terminated by a "
            "closing `---` line"
        )
    block = body[4:end]
    return _parse_frontmatter_block(block)


def _parse_frontmatter_block(block: str) -> dict:
    """Parse the constrained-shape YAML used by plan frontmatter.

    Supports:
    - Top-level scalars: ``key: "value"`` or ``key: value``.
    - Lists of strings: ``key:`` followed by indented ``- item`` lines.
    - Lists of dicts: ``key:`` followed by indented ``- subkey: value``
      blocks (for the `acceptance_criteria` shape).

    Comments (``#`` prefix) are stripped on parsed lines.
    """
    result: dict = {}
    lines = [_strip_comment(line) for line in block.split("\n")]
    i = 0
    while i < len(lines):
        line = lines[i]
        if not line.strip():
            i += 1
            continue
        if line.startswith(" "):
            raise FrontmatterParseError(
                f"unexpected indentation at line {i + 1}: {line!r}"
            )
        if ":" not in line:
            raise FrontmatterParseError(
                f"expected `key:` on line {i + 1}: {line!r}"
            )
        key, _, value_token = line.partition(":")
        key = key.strip()
        value_token = value_token.strip()
        if value_token:
            # Inline scalar value.
            result[key] = _unquote(value_token)
            i += 1
            continue
        # No inline value — collect the indented block beneath.
        block_lines: list[str] = []
        i += 1
        while i < len(lines) and (
            lines[i].startswith(" ") or not lines[i].strip()
        ):
            block_lines.append(lines[i])
            i += 1
        result[key] = _parse_indented_block(block_lines)
    return result


def _strip_comment(line: str) -> str:
    """Remove an inline comment (` # ...`) from a line.

    Plays it safe — only strips when ``#`` is preceded by whitespace
    and not inside quotes. Plan frontmatter uses ``#`` purely as a
    documentation comment marker (see `validation.md` examples).
    """
    in_str = False
    str_char = ""
    for idx, ch in enumerate(line):
        if ch in ("'", '"') and not in_str:
            in_str = True
            str_char = ch
        elif ch == str_char and in_str:
            in_str = False
        elif ch == "#" and not in_str:
            # Require whitespace immediately before the `#` so that a
            # literal ``#`` inside an unquoted value (rare) is left
            # alone unless it's clearly a comment marker.
            if idx == 0 or line[idx - 1] in (" ", "\t"):
                return line[:idx].rstrip()
    return line


def _unquote(token: str) -> str:
    if len(token) >= 2 and token[0] == token[-1] and token[0] in ("'", '"'):
        return token[1:-1]
    return token


def _parse_indented_block(block_lines: list[str]) -> list:
    """Parse the indented block under a list-valued key.

    Returns a list of strings (when items are ``- value``) or a list
    of dicts (when items are ``- subkey: value`` blocks).
    """
    items: list = []
    pending_dict: dict | None = None
    for raw in block_lines:
        line = raw.rstrip()
        if not line.strip():
            continue
        stripped = line.lstrip()
        indent = len(line) - len(stripped)
        if stripped.startswith("- "):
            # Start of a new list item.
            if pending_dict is not None:
                items.append(pending_dict)
                pending_dict = None
            entry = stripped[2:].strip()
            if ":" in entry and not (entry.startswith('"') or entry.startswith("'")):
                # Dict item — first key on the dash line.
                k, _, v = entry.partition(":")
                pending_dict = {k.strip(): _unquote(v.strip())}
            else:
                items.append(_unquote(entry))
        else:
            # Continuation of the most recent dict item — additional
            # subkeys at deeper indentation.
            if pending_dict is None:
                raise FrontmatterParseError(
                    f"indented continuation without a list-of-dict context: "
                    f"{raw!r}"
                )
            if ":" not in stripped:
                raise FrontmatterParseError(
                    f"expected `subkey: value` continuation, got: {raw!r}"
                )
            k, _, v = stripped.partition(":")
            pending_dict[k.strip()] = _unquote(v.strip())
        # Note: nested-of-nested structures aren't supported — the plan
        # frontmatter shape is intentionally flat-ish per `validation.md`.
        del indent  # documented for clarity; not used in the flat model
    if pending_dict is not None:
        items.append(pending_dict)
    return items


def extract_ac_ids_from_body(body: str) -> list[str]:
    """Extract `(ACn)` prefixes from the body's `## Acceptance criteria` section.

    Returns AC IDs in the order they appear. Duplicates are preserved
    (the verifier flags duplicates as their own error).
    """
    section_match = re.search(
        r"^##\s+Acceptance\s+criteria\b(.*?)(?=^##\s|\Z)",
        body,
        flags=re.MULTILINE | re.DOTALL | re.IGNORECASE,
    )
    if section_match is None:
        return []
    section = section_match.group(1)
    return _BODY_AC_PREFIX_RE.findall(section)


def verify_plan_completeness(body: str) -> dict:
    """Verify a plan body's YAML frontmatter against the schema in `validation.md`.

    See module docstring for the report shape. Plans without
    frontmatter return ``valid=True`` plus a single warning so the
    verifier degrades gracefully — required by AC4 (#184).
    """
    errors: list[dict[str, str]] = []
    warnings: list[dict[str, str]] = []

    try:
        frontmatter = parse_plan_frontmatter(body)
    except FrontmatterParseError as exc:
        return {
            "valid": False,
            "errors": [
                {"field": "frontmatter", "issue": f"parse error: {exc}"}
            ],
            "warnings": [],
        }

    if frontmatter is None:
        return {
            "valid": True,
            "errors": [],
            "warnings": [
                {
                    "field": "frontmatter",
                    "issue": (
                        "plan body has no YAML frontmatter; skipped — "
                        "no checks performed"
                    ),
                }
            ],
        }

    # 2 — required fields present.
    for field in _REQUIRED_FIELDS:
        if field not in frontmatter:
            errors.append(
                {"field": field, "issue": "required field missing"}
            )

    # Unknown extra fields → warn (per validation.md backward-compat policy).
    for field in frontmatter:
        if field not in _KNOWN_FIELDS:
            warnings.append(
                {
                    "field": field,
                    "issue": (
                        "unknown field — schema may have grown; passed "
                        "through unchanged but not validated"
                    ),
                }
            )

    # 4 — touched_files paths look plausible.
    touched = frontmatter.get("touched_files")
    if isinstance(touched, list):
        for idx, entry in enumerate(touched):
            if not isinstance(entry, str) or not entry.strip():
                errors.append(
                    {
                        "field": f"touched_files[{idx}]",
                        "issue": "must be a non-empty string",
                    }
                )
                continue
            try:
                p = PurePosixPath(entry)
            except (ValueError, TypeError):
                errors.append(
                    {
                        "field": f"touched_files[{idx}]",
                        "issue": "not a parseable path",
                    }
                )
                continue
            if p.is_absolute():
                errors.append(
                    {
                        "field": f"touched_files[{idx}]",
                        "issue": "absolute paths not allowed; use repo-relative",
                    }
                )
            if ".." in p.parts:
                errors.append(
                    {
                        "field": f"touched_files[{idx}]",
                        "issue": "`..` traversal not allowed",
                    }
                )
    elif touched is not None:
        errors.append(
            {"field": "touched_files", "issue": "must be a list of strings"}
        )

    # 5 — entities_introduced / entities_modified entries are non-empty strings.
    for key in ("entities_introduced", "entities_modified"):
        entries = frontmatter.get(key)
        if entries is None:
            continue
        if not isinstance(entries, list):
            errors.append(
                {"field": key, "issue": "must be a list of strings"}
            )
            continue
        for idx, entry in enumerate(entries):
            if not isinstance(entry, str) or not entry.strip():
                errors.append(
                    {
                        "field": f"{key}[{idx}]",
                        "issue": "must be a non-empty string",
                    }
                )

    # 3 — AC IDs cross-reference, plus 6 — no duplicates.
    yaml_acs = frontmatter.get("acceptance_criteria", [])
    yaml_ids: list[str] = []
    if not isinstance(yaml_acs, list):
        errors.append(
            {
                "field": "acceptance_criteria",
                "issue": "must be a list of {id, description} entries",
            }
        )
    else:
        for idx, entry in enumerate(yaml_acs):
            if not isinstance(entry, dict):
                errors.append(
                    {
                        "field": f"acceptance_criteria[{idx}]",
                        "issue": "must be a dict with id + description",
                    }
                )
                continue
            ac_id = entry.get("id", "")
            description = entry.get("description", "")
            if not isinstance(ac_id, str) or not _AC_ID_RE.match(ac_id):
                errors.append(
                    {
                        "field": f"acceptance_criteria[{idx}].id",
                        "issue": (
                            "must match ^AC\\d+$ (e.g., AC1, AC12)"
                        ),
                    }
                )
            else:
                yaml_ids.append(ac_id)
            if not isinstance(description, str) or not description.strip():
                errors.append(
                    {
                        "field": f"acceptance_criteria[{idx}].description",
                        "issue": "must be a non-empty string",
                    }
                )

    # Duplicate IDs.
    seen: set[str] = set()
    for ac_id in yaml_ids:
        if ac_id in seen:
            errors.append(
                {
                    "field": "acceptance_criteria",
                    "issue": f"duplicate id {ac_id!r}",
                }
            )
        seen.add(ac_id)

    # Bidirectional AC ID coherence.
    body_ids = extract_ac_ids_from_body(body)
    body_unique = set(body_ids)
    yaml_unique = set(yaml_ids)

    for ac_id in yaml_unique - body_unique:
        errors.append(
            {
                "field": f"acceptance_criteria[{ac_id}]",
                "issue": (
                    f"{ac_id} referenced in YAML but not found in "
                    "`## Acceptance criteria` markdown checkbox list"
                ),
            }
        )
    for ac_id in body_unique - yaml_unique:
        errors.append(
            {
                "field": f"body({ac_id})",
                "issue": (
                    f"{ac_id} appears as a checkbox in `## Acceptance "
                    "criteria` but has no entry in the YAML "
                    "acceptance_criteria list"
                ),
            }
        )

    # 7 — assumptions absent → warn (gentle nudge for future LLM verifier).
    if "assumptions" not in frontmatter:
        warnings.append(
            {
                "field": "assumptions",
                "issue": (
                    "field absent — 2.2 LLM verifier will have no "
                    "assumptions to check against current code state"
                ),
            }
        )

    return {
        "valid": not errors,
        "errors": errors,
        "warnings": warnings,
    }
