"""Epic verification for ensuring epic acceptance criteria are met.

This module provides the EpicVerifier class which verifies that all code changes
made for issues under an epic collectively meet the epic's acceptance criteria
before allowing the epic to close.

Key components:
- EpicVerifier: Main verification orchestrator
- ClaudeEpicVerificationModel: Claude-based implementation of EpicVerificationModel protocol

Design principles:
- Scoped commits: Only include commits linked to child issues (by bd-<id>: prefix)
- Agent-driven exploration: Verification agent explores repo using commit list
- Remediation issues: Auto-create with deduplication for unmet criteria
"""

from __future__ import annotations

import asyncio
import hashlib
import json
import os
import re
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

from src.core.models import (
    EpicVerdict,
    EpicVerificationResult,
    RetryConfig,
    UnmetCriterion,
)
from src.infra.epic_scope import EpicScopeAnalyzer

if TYPE_CHECKING:
    from collections.abc import AsyncIterator

    from src.core.protocols.events import MalaEventSink
    from src.core.protocols.infra import CommandRunnerPort, LockManagerPort
    from src.core.protocols.validation import EpicVerificationModel
    from src.infra.clients.beads_client import BeadsClient

# Spec path patterns from docs (case-insensitive)
SPEC_PATH_PATTERNS = [
    r"[Ss]ee\s+(specs/[\w/-]+\.(?:md|MD))",  # "See specs/foo/bar.md"
    r"[Ss]pec:\s*(specs/[\w/-]+\.(?:md|MD))",  # "Spec: specs/foo.md"
    r"\[(specs/[\w/-]+\.(?:md|MD))\]",  # "[specs/foo.md]"
    r"(?:^|[\s(])(specs/[\w/-]+\.(?:md|MD))(?:\s|[.,;:!?)]|$)",  # Bare "specs/foo.md" or "(specs/foo.md)"
]


# Default lock timeout for epic verification (5 minutes)
DEFAULT_EPIC_VERIFY_LOCK_TIMEOUT_SECONDS = 300


@asynccontextmanager
async def epic_verify_lock(
    epic_id: str,
    repo_path: Path,
    lock_manager: LockManagerPort | None,
    timeout_seconds: int | None = None,
) -> AsyncIterator[bool]:
    """Acquire per-epic verification lock with automatic cleanup.

    Args:
        epic_id: Epic identifier for lock key.
        repo_path: Repository path for lock context.
        lock_manager: Lock manager instance or None (no locking).
        timeout_seconds: Lock acquisition timeout. None uses default (300s).

    Yields True if lock acquired (or locking unavailable), False if timed out.
    Caller decides how to handle False (skip, return empty result, etc.)
    """
    if lock_manager is None:
        yield True
        return

    lock_key = f"epic_verify:{epic_id}"
    lock_agent_id = f"epic_verifier_{os.getpid()}"
    effective_timeout = (
        timeout_seconds
        if timeout_seconds is not None
        else DEFAULT_EPIC_VERIFY_LOCK_TIMEOUT_SECONDS
    )

    acquired = await asyncio.to_thread(
        lock_manager.wait_for_lock,
        lock_key,
        lock_agent_id,
        str(repo_path),
        effective_timeout,
    )

    try:
        yield acquired
    finally:
        if acquired:
            lock_manager.release_lock(lock_key, lock_agent_id, str(repo_path))


def _compute_criterion_hash(criterion: str) -> str:
    """Compute SHA256 hash of criterion text for deduplication."""
    return hashlib.sha256(criterion.encode()).hexdigest()


def extract_spec_paths(text: str) -> list[str]:
    """Extract spec file paths from text using documented patterns.

    Args:
        text: The text to search for spec paths.

    Returns:
        List of unique spec paths found.
    """
    paths: list[str] = []
    for pattern in SPEC_PATH_PATTERNS:
        matches = re.findall(pattern, text, re.MULTILINE)
        paths.extend(matches)
    # Deduplicate while preserving order
    seen: set[str] = set()
    result: list[str] = []
    for path in paths:
        if path not in seen:
            seen.add(path)
            result.append(path)
    return result


def _load_prompt_template() -> str:
    """Load the epic verification prompt template."""
    # Navigate from src/infra/ to src/prompts/
    prompt_path = Path(__file__).parent.parent / "prompts" / "epic_verification.md"
    template = prompt_path.read_text()
    escaped = template.replace("{", "{{").replace("}", "}}")
    for key in ("epic_criteria", "spec_content", "commit_range", "commit_list"):
        escaped = escaped.replace(f"{{{{{key}}}}}", f"{{{key}}}")
    return escaped


@dataclass
class EpicVerificationContext:
    """Context captured during verification for richer remediation issues."""

    epic_description: str
    spec_content: str | None
    child_ids: set[str]
    blocker_ids: set[str]
    commit_shas: list[str]
    commit_range: str | None
    commit_summary: str


def _extract_json_from_code_blocks(text: str) -> str | None:
    """Extract JSON content from markdown code blocks.

    This function properly handles responses with multiple code blocks by
    extracting each code block individually and returning the first one
    that contains valid JSON (i.e., starts with '{').

    Args:
        text: The raw model response text that may contain markdown code blocks.

    Returns:
        The JSON string extracted from the first JSON code block, or None if
        no valid JSON code block is found.
    """
    # Pattern to match individual code blocks non-greedily
    # Matches: ```json ... ``` or ``` ... ```
    # The .*? is non-greedy and DOTALL is NOT used, so we need to handle newlines
    # Use a pattern that matches until the closing ```
    code_block_pattern = r"```(?:json)?\s*\n?([\s\S]*?)```"

    matches = re.finditer(code_block_pattern, text)

    for match in matches:
        content = match.group(1).strip()
        # Check if this looks like JSON (starts with '{')
        if content.startswith("{"):
            return content

    return None


class ClaudeEpicVerificationModel:
    """Claude-based implementation of EpicVerificationModel protocol.

    Uses the Claude Agent SDK to verify epic acceptance criteria
    against scoped commits, matching the main agent execution path.
    """

    # Default model for epic verification
    DEFAULT_MODEL = "claude-sonnet-4-20250514"

    def __init__(
        self,
        model: str | None = None,
        timeout_ms: int = 300000,
        repo_path: Path | None = None,
        retry_config: RetryConfig | None = None,
    ):
        """Initialize ClaudeEpicVerificationModel.

        Args:
            model: The Claude model to use for verification. Defaults to
                DEFAULT_MODEL if not specified.
            timeout_ms: Timeout for model calls in milliseconds. Default is
                5 minutes (300000ms) to allow sufficient time for agent-driven
                repository exploration on non-trivial epics.
            repo_path: Repository path for agent execution context.
            retry_config: Configuration retained for compatibility with prior
                retry behavior. Currently unused but stored for future use.
        """
        self.model = model or self.DEFAULT_MODEL
        self.timeout_ms = timeout_ms
        self.repo_path = repo_path or Path.cwd()
        self.retry_config = retry_config or RetryConfig()
        self._prompt_template = _load_prompt_template()

    async def verify(
        self,
        epic_criteria: str,
        commit_range: str,
        commit_list: str,
        spec_content: str | None,
    ) -> EpicVerdict:
        """Verify if the commit scope satisfies the epic's acceptance criteria.

        Args:
            epic_criteria: The epic's acceptance criteria text.
            commit_range: Commit range hint covering child issue commits.
            commit_list: Authoritative list of commit SHAs to inspect.
            spec_content: Optional content of linked spec file.

        Returns:
            Structured verdict with pass/fail and unmet criteria details.

        """
        prompt = self._prompt_template.format(
            epic_criteria=epic_criteria,
            spec_content=spec_content or "No spec file available.",
            commit_range=commit_range or "Unavailable.",
            commit_list=commit_list or "No commits found.",
        )

        response_text = await self._verify_with_agent_sdk(prompt)
        return self._parse_verdict(response_text)

    async def _verify_with_agent_sdk(self, prompt: str) -> str:
        """Verify using Claude Agent SDK."""
        try:
            from claude_agent_sdk import (
                AssistantMessage,
                ClaudeAgentOptions,
                ClaudeSDKClient,
                TextBlock,
            )
        except ImportError as exc:
            return json.dumps(
                {
                    "passed": False,
                    "confidence": 0.0,
                    "reasoning": (
                        "claude_agent_sdk is not installed; epic verification "
                        f"requires the agent SDK ({exc})"
                    ),
                    "unmet_criteria": [],
                }
            )

        options = ClaudeAgentOptions(
            cwd=str(self.repo_path),
            permission_mode="bypassPermissions",
            model=self.model,
            system_prompt={"type": "preset", "preset": "claude_code"},
            setting_sources=["project", "user"],
            mcp_servers={},
            allowed_tools=[
                # Only these tools are permitted; all others (Edit, Write, etc.)
                # are blocked by omission. Bash is needed for git commands.
                "Bash",
                "Glob",
                "Grep",
                "Read",
                "Task",
            ],
            env=dict(os.environ),
        )

        response_chunks: list[str] = []
        async with ClaudeSDKClient(options=options) as client:
            async with asyncio.timeout(self.timeout_ms / 1000):
                await client.query(prompt)
                async for message in client.receive_response():
                    if isinstance(message, AssistantMessage):
                        for block in message.content:
                            if isinstance(block, TextBlock):
                                response_chunks.append(block.text)
                    # Note: ResultMessage.result (tool outputs) are intentionally
                    # excluded to avoid polluting the response with JSON that may
                    # appear in tool outputs (e.g., file contents). The final
                    # verdict JSON should come from the assistant's text response.

        return "".join(response_chunks).strip()

    def _parse_verdict(self, response_text: str) -> EpicVerdict:
        """Parse model response into EpicVerdict.

        Uses a robust code block parser that handles responses with multiple
        markdown code blocks by extracting each block individually, avoiding
        the issue where a greedy regex could span multiple blocks.

        Args:
            response_text: The raw model response text.

        Returns:
            Parsed EpicVerdict.
        """
        # Extract JSON from code blocks using the robust non-greedy parser
        json_str = _extract_json_from_code_blocks(response_text)

        if json_str is None:
            # Try to find raw JSON object (not in code block)
            json_match = re.search(
                r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", response_text, re.DOTALL
            )
            if json_match:
                json_str = json_match.group(0)
            else:
                # Fallback: assume entire response is JSON
                json_str = response_text

        try:
            data = json.loads(json_str)
        except json.JSONDecodeError:
            # If parsing fails, return a low-confidence failure
            return EpicVerdict(
                passed=False,
                unmet_criteria=[],
                confidence=0.0,
                reasoning=f"Failed to parse model response: {response_text[:500]}",
            )

        unmet_criteria = []
        for item in data.get("unmet_criteria", []):
            criterion = item.get("criterion", "")
            priority = item.get("priority", 1)
            # Accept int, float, or numeric string; cast to int, clamp to valid range 0-3
            if isinstance(priority, (int, float)):
                priority = max(0, min(3, int(priority)))
            elif isinstance(priority, str) and priority.isdigit():
                priority = max(0, min(3, int(priority)))
            else:
                priority = 1
            unmet_criteria.append(
                UnmetCriterion(
                    criterion=criterion,
                    evidence=item.get("evidence", ""),
                    priority=priority,
                    criterion_hash=_compute_criterion_hash(criterion),
                )
            )

        return EpicVerdict(
            passed=data.get("passed", False),
            unmet_criteria=unmet_criteria,
            confidence=data.get("confidence", 0.5),
            reasoning=data.get("reasoning", ""),
        )


class EpicVerifier:
    """Main verification orchestrator for epic acceptance criteria.

    Gathers epic data, computes scoped commits from child issue commits,
    invokes verification model, and creates remediation issues for unmet criteria.
    """

    def __init__(
        self,
        beads: BeadsClient,
        model: EpicVerificationModel,
        repo_path: Path,
        command_runner: CommandRunnerPort,
        retry_config: RetryConfig | None = None,
        lock_manager: LockManagerPort | None = None,
        event_sink: MalaEventSink | None = None,
        scope_analyzer: EpicScopeAnalyzer | None = None,
        max_diff_size_kb: int | None = None,
        lock_timeout_seconds: int | None = None,
    ):
        """Initialize EpicVerifier.

        Args:
            beads: BeadsClient for issue operations.
            model: EpicVerificationModel for verification.
            repo_path: Path to the repository.
            command_runner: CommandRunnerPort for executing commands.
            retry_config: Configuration for retry behavior.
            lock_manager: Optional lock manager for sequential processing.
            event_sink: Optional event sink for emitting verification lifecycle events.
            scope_analyzer: Optional EpicScopeAnalyzer for computing scoped commits.
                If not provided, a default instance is created.
            max_diff_size_kb: Maximum diff size in KB. If set and diff exceeds limit,
                verification is skipped and returns a verdict requiring human review.
            lock_timeout_seconds: Timeout in seconds for acquiring epic verification
                lock. None uses the default (300 seconds).
        """
        self.beads = beads
        self.model = model
        self.repo_path = repo_path
        self.retry_config = retry_config or RetryConfig()
        self.lock_manager = lock_manager
        self.event_sink = event_sink
        self._runner = command_runner
        self.scope_analyzer = scope_analyzer or EpicScopeAnalyzer(
            repo_path, self._runner
        )
        self.max_diff_size_kb = max_diff_size_kb
        self.lock_timeout_seconds = lock_timeout_seconds

    async def _get_diff_size_kb(self, commit_range: str) -> int | None:
        """Get approximate diff size in KB for a commit range.

        Uses git diff --stat to estimate the size based on line changes.
        Approximates 80 bytes per line for size estimation.

        Args:
            commit_range: Git commit range (e.g., "abc123^..def456") or single SHA.

        Returns:
            Estimated diff size in KB, or None if unable to compute.
        """
        # Parse commit range to get from/to commits
        # Format: "abc123^..def456" -> from="abc123^", to="def456"
        # Single commit: "abc123" -> from="abc123^", to="abc123"
        if ".." in commit_range:
            parts = commit_range.split("..", 1)
            if len(parts) != 2:
                return None
            from_commit, to_commit = parts[0], parts[1]
            if not from_commit or not to_commit:
                return None
        else:
            # Single commit - diff against its parent
            if not commit_range:
                return None
            from_commit = f"{commit_range}^"
            to_commit = commit_range

        result = await self._runner.run_async(
            ["git", "diff", "--numstat", from_commit, to_commit]
        )
        if not result.ok:
            return None

        total_lines = 0
        for line in result.stdout.strip().split("\n"):
            if not line:
                continue
            parts = line.split("\t")
            if len(parts) >= 2:
                added, removed = parts[0], parts[1]
                # Skip binary files (marked with "-")
                if added != "-" and removed != "-":
                    try:
                        total_lines += int(added) + int(removed)
                    except ValueError:
                        pass

        # Approximate 80 bytes per line, convert to KB
        return (total_lines * 80) // 1024

    async def verify_and_close_eligible(
        self,
        human_override_epic_ids: set[str] | None = None,
    ) -> EpicVerificationResult:
        """Check for epics eligible to close, verify each, and close those that pass.

        This method discovers eligible epics BEFORE attempting to close them,
        ensuring verification happens first.

        Args:
            human_override_epic_ids: Epic IDs to close without verification
                                     (explicit human override).

        Returns:
            Summary of verification results.
        """
        human_override_epic_ids = human_override_epic_ids or set()

        # Get eligible epics (those with all children closed)
        eligible_epics = await self._get_eligible_epics()

        # Aggregate results from individual verifications
        all_verdicts: dict[str, EpicVerdict] = {}
        all_remediation_issues: list[str] = []
        verified_count = 0
        passed_count = 0
        failed_count = 0

        for epic_id in eligible_epics:
            is_override = epic_id in human_override_epic_ids
            result = await self.verify_epic_with_options(
                epic_id,
                human_override=is_override,
                require_eligible=False,  # Already filtered by _get_eligible_epics
                close_epic=True,
            )
            # Aggregate result counts and verdicts
            all_verdicts.update(result.verdicts)
            all_remediation_issues.extend(result.remediation_issues_created)
            verified_count += result.verified_count
            passed_count += result.passed_count
            failed_count += result.failed_count

        return EpicVerificationResult(
            verified_count=verified_count,
            passed_count=passed_count,
            failed_count=failed_count,
            verdicts=all_verdicts,
            remediation_issues_created=all_remediation_issues,
        )

    async def verify_epic(self, epic_id: str) -> EpicVerdict:
        """Verify a single epic against its acceptance criteria.

        Args:
            epic_id: The epic ID to verify.

        Returns:
            EpicVerdict with verification result.
        """
        verdict, _ = await self._verify_epic_with_context(epic_id)
        return verdict

    async def _verify_epic_with_context(
        self, epic_id: str
    ) -> tuple[EpicVerdict, EpicVerificationContext | None]:
        """Verify a single epic and return verdict plus context."""
        # Get epic description (contains acceptance criteria)
        epic_description = await self.beads.get_issue_description_async(epic_id)
        if not epic_description:
            return (
                EpicVerdict(
                    passed=False,
                    unmet_criteria=[],
                    confidence=0.0,
                    reasoning="No acceptance criteria found for epic",
                ),
                None,
            )

        # Get child issue IDs
        child_ids = await self.beads.get_epic_children_async(epic_id)
        if not child_ids:
            return (
                EpicVerdict(
                    passed=False,
                    unmet_criteria=[],
                    confidence=0.0,
                    reasoning="No child issues found for epic",
                ),
                None,
            )

        # Get blocker issue IDs (remediation issues from previous verification runs)
        blocker_ids = await self.beads.get_epic_blockers_async(epic_id) or set()

        # Compute scoped commits using EpicScopeAnalyzer
        scoped = await self.scope_analyzer.compute_scoped_commits(
            child_ids, blocker_ids
        )
        if not scoped.commit_shas:
            return (
                EpicVerdict(
                    passed=False,
                    unmet_criteria=[],
                    confidence=0.0,
                    reasoning="No commits found for child issues",
                ),
                None,
            )

        # Check diff size limit if configured
        if self.max_diff_size_kb is not None and scoped.commit_range:
            diff_size_kb = await self._get_diff_size_kb(scoped.commit_range)
            if diff_size_kb is not None and diff_size_kb > self.max_diff_size_kb:
                criterion = (
                    f"Diff size must not exceed {self.max_diff_size_kb} KB "
                    "for automated verification"
                )
                return (
                    EpicVerdict(
                        passed=False,
                        unmet_criteria=[
                            UnmetCriterion(
                                criterion=criterion,
                                evidence=(
                                    f"Diff size ({diff_size_kb} KB) exceeds limit "
                                    f"({self.max_diff_size_kb} KB). "
                                    "Requires human review."
                                ),
                                priority=1,  # P1 blocking
                                criterion_hash=_compute_criterion_hash(criterion),
                            )
                        ],
                        confidence=0.0,
                        reasoning=(
                            f"Diff size ({diff_size_kb} KB) exceeds limit "
                            f"({self.max_diff_size_kb} KB). Requires human review."
                        ),
                    ),
                    None,
                )

        # Extract and load spec content if referenced
        spec_paths = extract_spec_paths(epic_description)
        spec_content = None
        for spec_path in spec_paths:
            full_path = self.repo_path / spec_path
            if full_path.exists():
                try:
                    spec_content = await asyncio.to_thread(full_path.read_text)
                    break  # Use first found spec
                except OSError:
                    pass

        context = EpicVerificationContext(
            epic_description=epic_description,
            spec_content=spec_content,
            child_ids=child_ids,
            blocker_ids=blocker_ids,
            commit_shas=scoped.commit_shas,
            commit_range=scoped.commit_range,
            commit_summary=scoped.commit_summary,
        )

        # Invoke verification model with error handling
        # Per spec: timeouts/errors should trigger human review, not abort
        try:
            result = await self.model.verify(
                epic_description,
                scoped.commit_range or "",
                scoped.commit_summary,
                spec_content,
            )
            verdict = EpicVerdict(
                passed=result.passed,
                unmet_criteria=[
                    UnmetCriterion(
                        criterion=c.criterion,
                        evidence=c.evidence,
                        priority=c.priority,
                        criterion_hash=c.criterion_hash,
                    )
                    for c in result.unmet_criteria
                ],
                confidence=result.confidence,
                reasoning=result.reasoning,
            )
        except Exception as e:
            verdict = EpicVerdict(
                passed=False,
                unmet_criteria=[],
                confidence=0.0,
                reasoning=f"Model verification failed: {e}",
            )

        return verdict, context

    async def _get_eligible_epics(self) -> list[str]:
        """Get list of epics eligible for closure.

        Returns:
            List of epic IDs that have all children closed.
        """
        # Prefer bd epic status --eligible-only when supported; fall back to
        # full status and filter eligible_for_close.
        result = await self._runner.run_async(
            ["bd", "epic", "status", "--eligible-only", "--json"]
        )
        if not result.ok:
            result = await self._runner.run_async(["bd", "epic", "status", "--json"])
        if not result.ok:
            return []
        try:
            rows = json.loads(result.stdout)
            eligible: list[str] = []
            if isinstance(rows, list):
                for row in rows:
                    if not isinstance(row, dict):
                        continue
                    if row.get("eligible_for_close"):
                        epic = row.get("epic") or {}
                        if isinstance(epic, dict):
                            epic_id = epic.get("id")
                            if epic_id:
                                eligible.append(str(epic_id))
            return eligible
        except json.JSONDecodeError:
            return []

    async def _is_epic_eligible(self, epic_id: str) -> bool:
        """Check if a specific epic is eligible for closure.

        An epic is eligible if all its children are closed.

        Args:
            epic_id: The epic ID to check.

        Returns:
            True if the epic is eligible for closure, False otherwise.
        """
        eligible_epics = await self._get_eligible_epics()
        return epic_id in eligible_epics

    async def _get_ineligibility_reason(self, epic_id: str) -> str | None:
        """Get reason why an epic is not eligible for closure.

        Args:
            epic_id: The epic ID to check.

        Returns:
            Human-readable reason if ineligible, None if eligible or status unavailable.
        """
        result = await self._runner.run_async(["bd", "epic", "status", "--json"])
        if not result.ok:
            return None
        try:
            rows = json.loads(result.stdout)
            if not isinstance(rows, list):
                return None
            for row in rows:
                if not isinstance(row, dict):
                    continue
                epic = row.get("epic") or {}
                if isinstance(epic, dict) and epic.get("id") == epic_id:
                    if row.get("eligible_for_close") is True:
                        return None  # Actually eligible
                    try:
                        total = int(row.get("total_children") or 0)
                        closed = int(row.get("closed_children") or 0)
                        open_count = max(0, total - closed)
                    except (TypeError, ValueError):
                        return "Epic is not eligible for closure"
                    if open_count > 0:
                        return f"{open_count} of {total} child issues still open"
                    # Epic found but not eligible for unknown reason
                    return "Epic is not eligible for closure"
            # Epic not found in status output
            return f"Epic {epic_id} not found"
        except json.JSONDecodeError:
            return None

    async def verify_and_close_epic(
        self,
        epic_id: str,
        human_override: bool = False,
    ) -> EpicVerificationResult:
        """Verify and close a single specific epic if eligible.

        This method checks if the specified epic is eligible (all children closed),
        then verifies it if eligible, and closes it if verification passes.

        Args:
            epic_id: The epic ID to verify and close.
            human_override: If True, bypass verification and close directly.

        Returns:
            Summary of verification result for this epic.
        """
        return await self.verify_epic_with_options(
            epic_id,
            human_override=human_override,
            require_eligible=True,
            close_epic=True,
        )

    async def verify_epic_with_options(
        self,
        epic_id: str,
        *,
        human_override: bool = False,
        require_eligible: bool = True,
        close_epic: bool = True,
    ) -> EpicVerificationResult:
        """Verify a specific epic with optional eligibility and closing behavior.

        Args:
            epic_id: The epic ID to verify.
            human_override: If True, bypass verification and (optionally) close.
            require_eligible: If True, only run when all children are closed.
            close_epic: If True, close the epic after a passing verification.

        Returns:
            Summary of verification result for this epic.
        """
        verdicts: dict[str, EpicVerdict] = {}
        remediation_issues: list[str] = []
        passed_count = 0
        failed_count = 0
        verified_count = 0

        if require_eligible and not await self._is_epic_eligible(epic_id):
            reason = await self._get_ineligibility_reason(epic_id)
            return EpicVerificationResult(
                verified_count=0,
                passed_count=0,
                failed_count=0,
                verdicts={},
                remediation_issues_created=[],
                ineligibility_reason=reason,
            )

        if human_override:
            verified_count = 1
            if close_epic:
                closed = await self.beads.close_async(epic_id)
                if closed:
                    passed_count = 1
                    verdicts[epic_id] = EpicVerdict(
                        passed=True,
                        unmet_criteria=[],
                        confidence=1.0,
                        reasoning="Human override - bypassed verification",
                    )
                else:
                    failed_count = 1
                    verdicts[epic_id] = EpicVerdict(
                        passed=False,
                        unmet_criteria=[],
                        confidence=0.0,
                        reasoning="Human override close failed - epic could not be closed",
                    )
                    if self.event_sink is not None:
                        self.event_sink.on_epic_verification_failed(epic_id, 0, [])
            else:
                verdicts[epic_id] = EpicVerdict(
                    passed=True,
                    unmet_criteria=[],
                    confidence=1.0,
                    reasoning="Human override - bypassed verification (no close)",
                )
                passed_count = 1

            return EpicVerificationResult(
                verified_count=verified_count,
                passed_count=passed_count,
                failed_count=failed_count,
                verdicts=verdicts,
                remediation_issues_created=remediation_issues,
            )

        async with epic_verify_lock(
            epic_id, self.repo_path, self.lock_manager, self.lock_timeout_seconds
        ) as acquired:
            if not acquired:
                return EpicVerificationResult(
                    verified_count=0,
                    passed_count=0,
                    failed_count=0,
                    verdicts={},
                    remediation_issues_created=[],
                )

            if self.event_sink is not None:
                self.event_sink.on_epic_verification_started(epic_id)

            verdict, context = await self._verify_epic_with_context(epic_id)
            verdicts[epic_id] = verdict
            verified_count = 1

            # Create remediation/advisory issues for any unmet criteria
            blocking_ids: list[str] = []
            informational_ids: list[str] = []
            if verdict.unmet_criteria:
                (
                    blocking_ids,
                    informational_ids,
                ) = await self.create_remediation_issues(epic_id, verdict, context)
                remediation_issues.extend(blocking_ids)
                remediation_issues.extend(informational_ids)

            # Epic fails if: has P0/P1 blocking issues OR model explicitly said passed=false
            if blocking_ids or not verdict.passed:
                if blocking_ids:
                    await self.add_epic_blockers(epic_id, blocking_ids)
                failed_count = 1
                if self.event_sink is not None:
                    self.event_sink.on_epic_verification_failed(
                        epic_id, len(blocking_ids), blocking_ids
                    )
            else:
                # No blocking issues and verdict.passed=True - close epic if requested (may have P2/P3 advisories)
                if close_epic:
                    closed = await self.beads.close_async(epic_id)
                    if closed:
                        passed_count = 1
                        if self.event_sink is not None:
                            self.event_sink.on_epic_verification_passed(
                                epic_id, verdict.confidence
                            )
                    else:
                        # Close failed - treat as verification failure
                        failed_count = 1
                        if self.event_sink is not None:
                            self.event_sink.on_epic_verification_failed(epic_id, 0, [])
                else:
                    passed_count = 1
                    if self.event_sink is not None:
                        self.event_sink.on_epic_verification_passed(
                            epic_id, verdict.confidence
                        )
        return EpicVerificationResult(
            verified_count=verified_count,
            passed_count=passed_count,
            failed_count=failed_count,
            verdicts=verdicts,
            remediation_issues_created=remediation_issues,
        )

    def _truncate_text(self, text: str, max_chars: int = 4000) -> str:
        """Truncate text for issue descriptions to keep context manageable."""
        if len(text) <= max_chars:
            return text
        return f"{text[:max_chars]}\n\n[truncated]"

    def _format_remediation_context(
        self, context: EpicVerificationContext | None
    ) -> str:
        """Build a rich context block for remediation issue descriptions."""
        if context is None:
            return ""

        sections: list[str] = []
        sections.append(
            "## Epic Description / Acceptance Criteria\n"
            + self._truncate_text(context.epic_description)
        )

        if context.spec_content:
            sections.append(
                "## Spec Content\n" + self._truncate_text(context.spec_content)
            )

        commit_range = context.commit_range or "Unavailable"
        commit_list = context.commit_summary or "No commits found."
        sections.append(
            f"## Commit Scope\n- Range hint: {commit_range}\n- Commits:\n{commit_list}"
        )

        if context.child_ids:
            child_list = "\n".join(f"- {cid}" for cid in sorted(context.child_ids))
            sections.append(f"## Child Issues\n{child_list}")

        if context.blocker_ids:
            blocker_list = "\n".join(f"- {bid}" for bid in sorted(context.blocker_ids))
            sections.append(f"## Existing Blockers\n{blocker_list}")

        return "\n\n".join(sections)

    async def create_remediation_issues(
        self,
        epic_id: str,
        verdict: EpicVerdict,
        context: EpicVerificationContext | None = None,
    ) -> tuple[list[str], list[str]]:
        """Create issues for unmet criteria, return blocking and informational IDs.

        Deduplication: Checks for existing issues with matching
        epic_remediation:<epic_id>:<criterion_hash> tag before creating.

        P0/P1 issues are blocking (parented to epic, block closure).
        P2/P3 issues are informational (standalone, don't block closure).

        Args:
            epic_id: The epic ID the issues are for.
            verdict: The verification verdict with unmet criteria.
            context: Optional verification context for richer issue descriptions.

        Returns:
            Tuple of (blocking_issue_ids, informational_issue_ids).
        """
        blocking_ids: list[str] = []
        informational_ids: list[str] = []
        context_block = self._format_remediation_context(context)

        for criterion in verdict.unmet_criteria:
            is_blocking = criterion.priority <= 1  # P0/P1 are blocking

            # Build dedup tag
            dedup_tag = f"epic_remediation:{epic_id}:{criterion.criterion_hash}"

            # Check for existing issue with this tag
            existing_id = await self.beads.find_issue_by_tag_async(dedup_tag)
            if existing_id:
                if is_blocking:
                    blocking_ids.append(existing_id)
                else:
                    informational_ids.append(existing_id)
                continue

            # Create new remediation issue
            # Sanitize criterion text for title: remove newlines, collapse whitespace
            sanitized_criterion = re.sub(r"\s+", " ", criterion.criterion.strip())
            prefix = "[Remediation]" if is_blocking else "[Advisory]"
            title = f"{prefix} {sanitized_criterion[:60]}"
            if len(sanitized_criterion) > 60:
                title = title + "..."

            priority_label = f"P{criterion.priority}"
            blocking_note = (
                "Address this criterion to unblock epic closure."
                if is_blocking
                else "This is advisory (P2/P3) and does not block epic closure."
            )

            description = f"""## Context
This issue was auto-created by epic verification for epic `{epic_id}`.

{context_block}

## Unmet Criterion
{criterion.criterion}

## Evidence
{criterion.evidence}

## Priority
{priority_label} ({"blocking" if is_blocking else "informational"})

## Resolution
{blocking_note} When complete, close this issue.
"""

            priority_str = f"P{criterion.priority}"

            # Blocking issues are parented to epic; informational are standalone
            parent_id = epic_id if is_blocking else None

            issue_id = await self.beads.create_issue_async(
                title=title,
                description=description,
                priority=priority_str,
                tags=[dedup_tag, "auto_generated"],
                parent_id=parent_id,
            )
            if issue_id:
                if is_blocking:
                    blocking_ids.append(issue_id)
                else:
                    informational_ids.append(issue_id)
                # Emit remediation created event
                if self.event_sink is not None:
                    self.event_sink.on_epic_remediation_created(
                        epic_id, issue_id, criterion.criterion
                    )

        return blocking_ids, informational_ids

    async def add_epic_blockers(
        self, epic_id: str, blocker_issue_ids: list[str]
    ) -> None:
        """Add issues as blockers of the epic via bd dep add.

        Args:
            epic_id: The epic to block.
            blocker_issue_ids: Issues that must be resolved before epic closes.
        """
        for blocker_id in blocker_issue_ids:
            await self._runner.run_async(
                ["bd", "dep", "add", epic_id, "--blocked-by", blocker_id]
            )
