"""verify_diff_covers_plan — entity comparator for plan-vs-diff drift (#185).

Sibling to ``verify_plan_completeness`` (#184). Catches error class 1
(concept loss between plan and implementation) and error class 3
(plan incomplete relative to user intent) by comparing the plan's
declared scope against the actual git diff.

Public surface:
- ``verify_diff_covers_plan(plan_body, diff_text, evidence_text,
  project_rules=None) -> dict`` — orchestrator returning the report
  shape from #185 plus the ``rule_violations`` extension from #192.
- ``check_project_rules(project_rules, diff_files, evidence_text)
  -> list[dict]`` — apply project-specific rules (#192) — file-pattern
  triggers paired with required cross-document edit signals.
- ``parse_diff_files(diff_text) -> list[str]`` — extract repo-relative
  paths from a unified diff.
- ``extract_added_lines(diff_text) -> list[str]`` — `+` lines
  (excluding the `+++ b/path` headers).
- ``extract_changed_lines(diff_text) -> list[str]`` — both `+` and
  `-` lines.

The deterministic shape: regex-based, no parser tree. False
positives on extremely short entity names ("a", "x") are accepted
per the issue — the `validation.md` plan-frontmatter discipline
keeps entity names meaningful.
"""
from __future__ import annotations

import re

from .plan import parse_plan_frontmatter, FrontmatterParseError


# `+++ b/path/to/file` (added/modified) or `--- a/path/to/file` (deleted).
# `/dev/null` is the empty-side marker for added or deleted files.
_DIFF_FILE_HEADER_RE = re.compile(
    r"^(?:\+\+\+|---)\s+(?:[ab]/)(?P<path>\S+)$",
    flags=re.MULTILINE,
)
# Match `(ACnnn)` references — same convention as in the plan body.
_AC_PREFIX_RE = re.compile(r"\(\s*(AC\d+)\s*\)")
# Match a unified-diff content line: `+...` (additions, but not `+++`)
# or `-...` (deletions, but not `---`). Plain context lines start with ` `.
_ADDED_LINE_RE = re.compile(r"^\+(?!\+\+)(?P<content>.*)$", flags=re.MULTILINE)
_REMOVED_LINE_RE = re.compile(r"^-(?!--)(?P<content>.*)$", flags=re.MULTILINE)


def parse_diff_files(diff_text: str) -> list[str]:
    """Return repo-relative paths touched by the diff.

    Deduplicated, in first-appearance order. Drops `/dev/null`
    placeholders. Handles both `--- a/x` and `+++ b/x` markers.
    """
    seen: list[str] = []
    seen_set: set[str] = set()
    for match in _DIFF_FILE_HEADER_RE.finditer(diff_text):
        path = match.group("path")
        if path == "/dev/null":
            continue
        if path not in seen_set:
            seen_set.add(path)
            seen.append(path)
    return seen


def extract_added_lines(diff_text: str) -> list[str]:
    """Return content of every added (`+...`) line, excluding `+++` headers."""
    return [m.group("content") for m in _ADDED_LINE_RE.finditer(diff_text)]


def extract_changed_lines(diff_text: str) -> list[str]:
    """Return content of every added or removed line, excluding headers."""
    out = [m.group("content") for m in _ADDED_LINE_RE.finditer(diff_text)]
    out.extend(m.group("content") for m in _REMOVED_LINE_RE.finditer(diff_text))
    return out


def _entity_present(entity: str, lines: list[str]) -> bool:
    """True if ``entity`` appears as a word-boundary match in any line."""
    pattern = re.compile(rf"\b{re.escape(entity)}\b")
    return any(pattern.search(line) for line in lines)


def check_project_rules(
    project_rules: list[dict] | None,
    diff_files: list[str],
    evidence_text: str,
) -> list[dict]:
    """Apply project-specific rules to the diff (#192).

    Each rule is a dict with the following shape:

    ```
    {
        "name": "<rule-id>",
        "if_diff_touches_pattern": "<regex matched against repo-relative path>",
        "must_also_touch_discussion": <int issue/discussion number>,
        "severity": "warning" | "blocker",  # default "warning"
        "message": "<operator-facing explanation>",
    }
    ```

    The rule fires when the diff touches at least one file whose
    repo-relative path matches `if_diff_touches_pattern` AND none of
    the recognized "edited Discussion #N" signals appear in
    `evidence_text`. Recognized signals:

    1. A `## Edits to Discussion #<N>` heading anywhere in the
       evidence text (PR description convention).
    2. A `Updated Discussion #<N> with: ...` line (PR-comment convention).

    Either signal counts; absence of both fires the rule.

    Malformed rules (missing pattern, missing `must_also_touch_discussion`,
    invalid regex) are skipped silently — verification never blocks
    on its own configuration drift.
    """
    if not project_rules:
        return []

    violations: list[dict] = []
    for rule in project_rules:
        if not isinstance(rule, dict):
            continue
        name = rule.get("name") or "<unnamed>"
        pattern = rule.get("if_diff_touches_pattern")
        must_touch = rule.get("must_also_touch_discussion")
        severity = rule.get("severity") or "warning"
        message = rule.get("message") or "rule violation"

        if not isinstance(pattern, str) or not isinstance(must_touch, int):
            continue
        try:
            file_re = re.compile(pattern)
        except re.error:
            continue

        triggered = [
            f for f in diff_files
            if isinstance(f, str) and file_re.search(f)
        ]
        if not triggered:
            continue

        # Rule triggered — look for a "edited #N" signal in evidence.
        edits_section_re = re.compile(
            rf"^\s*##\s+Edits\s+to\s+Discussion\s+#{must_touch}\b",
            flags=re.MULTILINE | re.IGNORECASE,
        )
        comment_re = re.compile(
            rf"Updated\s+Discussion\s+#{must_touch}\s+with",
            flags=re.IGNORECASE,
        )
        if edits_section_re.search(evidence_text):
            continue
        if comment_re.search(evidence_text):
            continue

        violations.append({
            "rule": name,
            "severity": severity,
            "message": message,
            "evidence": triggered,
        })

    return violations


def _ac_coverage(
    diff_text: str, evidence_text: str, ac_ids: list[str]
) -> tuple[list[str], dict[str, list[str]]]:
    """Compute (uncovered, evidence_by_id) for the AC IDs.

    An AC is "covered" if `(ACn)` appears anywhere in the diff or the
    evidence_text. Evidence captures the line excerpt where the
    reference was seen, prefixed with a source tag for the operator.
    """
    evidence: dict[str, list[str]] = {ac: [] for ac in ac_ids}

    def _scan(source_label: str, text: str) -> None:
        for line_no, line in enumerate(text.splitlines(), start=1):
            for match in _AC_PREFIX_RE.finditer(line):
                ac_id = match.group(1)
                if ac_id in evidence:
                    excerpt = line.strip()[:120]
                    evidence[ac_id].append(
                        f"{source_label}:{line_no}: {excerpt}"
                    )

    _scan("diff", diff_text)
    _scan("evidence", evidence_text)
    uncovered = [ac for ac in ac_ids if not evidence[ac]]
    return uncovered, evidence


def verify_diff_covers_plan(
    plan_body: str,
    diff_text: str,
    evidence_text: str = "",
    project_rules: list[dict] | None = None,
) -> dict:
    """Compare plan frontmatter against the actual diff.

    See module docstring for the report shape. Plans without
    frontmatter return ``covered=True`` plus a single warning so the
    verifier degrades gracefully (#185 AC5).

    `project_rules` (#192) are applied independently of the
    frontmatter comparison — they fire on file-pattern triggers in
    the diff regardless of whether the plan has frontmatter, so a
    "skill prose ↔ #190 paired edit" rule still fires on
    frontmatter-less PRs that touch skill prose.
    """
    diff_files_for_rules = parse_diff_files(diff_text)
    rule_violations = check_project_rules(
        project_rules, diff_files_for_rules, evidence_text
    )

    try:
        frontmatter = parse_plan_frontmatter(plan_body)
    except FrontmatterParseError as exc:
        return {
            "covered": False,
            "warnings": [
                {"field": "frontmatter", "issue": f"plan parse error: {exc}"}
            ],
            "missing_files": [],
            "unexpected_files": [],
            "missing_entities_introduced": [],
            "missing_entities_modified": [],
            "unchecked_ac": [],
            "ac_coverage_evidence": {},
            "rule_violations": rule_violations,
        }

    if frontmatter is None:
        # Frontmatter-less plan: the entity comparator skips, but
        # project rules still fire on file-pattern triggers — a
        # "skill-prose ↔ #190 paired edit" rule must surface drift
        # whether or not the PR carries a structured plan body.
        covered = not rule_violations
        return {
            "covered": covered,
            "warnings": [
                {
                    "field": "frontmatter",
                    "issue": (
                        "plan body has no YAML frontmatter; skipped — "
                        "no entity-coverage checks performed"
                    ),
                }
            ],
            "missing_files": [],
            "unexpected_files": [],
            "missing_entities_introduced": [],
            "missing_entities_modified": [],
            "unchecked_ac": [],
            "ac_coverage_evidence": {},
            "rule_violations": rule_violations,
        }

    diff_files = parse_diff_files(diff_text)
    diff_files_set = set(diff_files)
    added_lines = extract_added_lines(diff_text)
    changed_lines = extract_changed_lines(diff_text)

    # 1 — touched_files coherence (bidirectional drift).
    declared_files = frontmatter.get("touched_files") or []
    if not isinstance(declared_files, list):
        declared_files = []
    declared_set = set(
        p for p in declared_files if isinstance(p, str)
    )
    missing_files = [
        p for p in declared_files
        if isinstance(p, str) and p not in diff_files_set
    ]
    unexpected_files = [
        p for p in diff_files if p not in declared_set
    ]

    # 2 — entities_introduced should appear in additions.
    introduced = frontmatter.get("entities_introduced") or []
    if not isinstance(introduced, list):
        introduced = []
    missing_entities_introduced = [
        e for e in introduced
        if isinstance(e, str) and e.strip()
        and not _entity_present(e, added_lines)
    ]

    # 3 — entities_modified should appear anywhere in the diff
    # (context counts: presence in a hunk's context line implies the
    # entity sits inside or adjacent to the changes). A definition
    # like `def existing_function():` typically appears as a context
    # line above the actual `+`/`-` changes.
    modified = frontmatter.get("entities_modified") or []
    if not isinstance(modified, list):
        modified = []
    diff_lines = diff_text.splitlines()
    missing_entities_modified = [
        e for e in modified
        if isinstance(e, str) and e.strip()
        and not _entity_present(e, diff_lines)
    ]

    # 4 — AC coverage (diff text + evidence text).
    yaml_acs = frontmatter.get("acceptance_criteria") or []
    ac_ids: list[str] = []
    if isinstance(yaml_acs, list):
        for entry in yaml_acs:
            if isinstance(entry, dict):
                ac_id = entry.get("id")
                if isinstance(ac_id, str) and ac_id.strip():
                    ac_ids.append(ac_id)
    unchecked_ac, ac_coverage_evidence = _ac_coverage(
        diff_text, evidence_text, ac_ids
    )

    covered = (
        not missing_files
        and not unexpected_files
        and not missing_entities_introduced
        and not missing_entities_modified
        and not unchecked_ac
        and not rule_violations
    )

    return {
        "covered": covered,
        "warnings": [],
        "missing_files": missing_files,
        "unexpected_files": unexpected_files,
        "missing_entities_introduced": missing_entities_introduced,
        "missing_entities_modified": missing_entities_modified,
        "unchecked_ac": unchecked_ac,
        "ac_coverage_evidence": ac_coverage_evidence,
        "rule_violations": rule_violations,
    }
