# Copyright (c) 2026 MergeGuide, Inc. All rights reserved.
# SPDX-License-Identifier: Proprietary
"""Pull request event handler for MergeGuide policy checks.

This module provides the main entry point for processing GitHub pull request
webhooks. It orchestrates the complete PR check workflow including:

1. Posting initial "pending" commit status to GitHub
2. Fetching the PR diff content
3. Evaluating the diff against configured security policies
4. Storing evidence artifacts for compliance/audit purposes
5. Posting the final pass/fail commit status
6. Creating or updating the bot comment with detailed results

The module contains two main handlers:
- PRHandler: Processes pull_request events for policy evaluation
- InstallationHandler: Manages GitHub App installation lifecycle events

Typical usage in a Lambda function:
    >>> from mergeguide.github.pr_handler import PRHandler
    >>> from mergeguide.github.api import GitHubAPI
    >>> from mergeguide.engine.evaluator import PolicyEngine
    >>>
    >>> github = GitHubAPI(app_id=123, private_key="...")
    >>> engine = PolicyEngine.from_rules(rules)
    >>> handler = PRHandler(github, engine)
    >>>
    >>> # In webhook handler
    >>> result = await handler.handle_pull_request(event)
    >>> if not result.passed:
    ...     print(f"PR blocked: {result.errors_count} errors")
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

from mergeguide.scanning.commit_scanner import (
    CommitSignatureConfig,
    CommitSignatureScanner,
    SignaturePolicyMode,
    SignatureScanResult,
)

from .api import GitHubAPI, GitHubPermissionError
from .comments import (
    COMMENT_MARKER,
    format_error_comment,
    format_pr_comment,
    format_scanner_sections,
)
from .specialized_scanners import (
    SpecializedScannerResults,
    run_specialized_scanners,
)
from .validation import (
    ValidationError,
    validate_author,
    validate_branch,
    validate_commit_sha,
    validate_org_id,
    validate_pr_number,
    validate_repo,
)
from .webhook import WebhookEvent

if TYPE_CHECKING:
    from mergeguide.engine.evaluator import EvaluationContext, PolicyEngine, PolicyOutput
    from mergeguide.engine.orchestrator import DualLayerOrchestrator
    from mergeguide.evidence.provenance import ProvenanceRecord
    from mergeguide.evidence.storage import EvidenceStorage

logger = logging.getLogger(__name__)

# Manifest filenames that trigger SBOM generation when changed in a PR.
# Matches the list from VulnerabilityScanner.MANIFEST_FILES plus requirements*.txt glob.
MANIFEST_FILENAMES = {
    "package.json",
    "package-lock.json",
    "requirements.txt",
    "Pipfile",
    "pyproject.toml",
    "go.mod",
    "go.sum",
    "Cargo.toml",
    "Cargo.lock",
    "pom.xml",
    "Gemfile",
    "Gemfile.lock",
    "composer.json",
    "composer.lock",
    "packages.config",
}


@dataclass
class SbomVuln:
    """A vulnerability extracted from a CycloneDX SBOM."""

    id: str
    description: str
    cvss_score: float
    severity: str


def _parse_sbom_for_vulns(sbom_data: dict[str, Any]) -> list[SbomVuln]:
    """Parse a CycloneDX SBOM for high/critical vulnerabilities.

    Iterates the ``vulnerabilities`` array in the SBOM and extracts any
    entry whose highest CVSS score is >= 7.0 (high) or >= 9.0 (critical).

    Args:
        sbom_data: Parsed CycloneDX SBOM dict.

    Returns:
        List of :class:`SbomVuln` with ``cvss_score >= 7.0``, sorted
        descending by CVSS score.
    """
    vulns: list[SbomVuln] = []
    for vuln in sbom_data.get("vulnerabilities", []):
        vuln_id = vuln.get("id", "UNKNOWN")
        description = vuln.get("description", "")

        # Find the highest CVSS score across all ratings
        max_score = 0.0
        severity = "unknown"
        for rating in vuln.get("ratings", []):
            score = rating.get("score", 0.0)
            if isinstance(score, (int, float)) and score > max_score:
                max_score = float(score)
                severity = rating.get("severity", "unknown")

        if max_score >= 7.0:
            vulns.append(
                SbomVuln(
                    id=vuln_id,
                    description=description,
                    cvss_score=max_score,
                    severity=severity,
                )
            )

    vulns.sort(key=lambda v: v.cvss_score, reverse=True)
    return vulns


def _is_manifest_file(filepath: str) -> bool:
    """Check if a file path refers to a dependency manifest.

    Matches exact filenames from MANIFEST_FILENAMES, plus glob patterns
    for requirements*.txt and *.csproj files.

    Args:
        filepath: Full file path from the diff (e.g., "src/package.json").

    Returns:
        True if the file is a recognized dependency manifest.
    """
    import os

    basename = os.path.basename(filepath)
    if basename in MANIFEST_FILENAMES:
        return True
    if basename.startswith("requirements") and basename.endswith(".txt"):
        return True
    if basename.endswith(".csproj"):
        return True
    return False


@dataclass
class PRCheckResult:
    """Encapsulates the outcome of a pull request policy check.

    This dataclass captures all relevant information about a completed
    policy evaluation, including pass/fail status, violation counts,
    and references to stored evidence.

    Attributes:
        passed: True if the PR passed all policy checks (no error-level violations).
        evaluation_id: Unique identifier for this evaluation run (UUID format).
        violations_count: Total number of violations found across all severities.
        errors_count: Number of error-level violations (blocking).
        warnings_count: Number of warning-level violations (non-blocking).
        evidence_key: S3 key or storage path for the evidence artifact, if stored.
        comment_id: GitHub comment ID for the bot's PR comment, if posted.

    Example:
        >>> result = PRCheckResult(
        ...     passed=False,
        ...     evaluation_id="a1b2c3d4-...",
        ...     violations_count=3,
        ...     errors_count=1,
        ...     warnings_count=2,
        ...     evidence_key="acme-corp/my-repo/2024-01/a1b2c3d4.json",
        ...     comment_id=12345678,
        ... )
        >>> result.passed
        False
    """

    passed: bool
    evaluation_id: str
    violations_count: int
    errors_count: int
    warnings_count: int
    evidence_key: str | None = None
    sbom_key: str | None = None
    comment_id: int | None = None


class PRHandler:
    """Handles pull_request webhook events for security policy evaluation.

    This is the main coordinator for PR-based policy checks. It implements
    the complete workflow from receiving a webhook to posting results back
    to GitHub.

    The check workflow:
        1. Post "pending" commit status to GitHub
        2. Fetch the PR diff via GitHub API
        3. Evaluate the diff against all configured policies
        4. Store an evidence artifact (if storage configured)
        5. Post final commit status (success/failure/error)
        6. Post or update the bot's PR comment with detailed results

    The handler is designed to be stateless and can be reused across
    multiple webhook invocations. All GitHub state is managed through
    the GitHubAPI client.

    Attributes:
        github: GitHubAPI client for all GitHub interactions.
        engine: PolicyEngine instance for running policy evaluations.
        storage: Optional EvidenceStorage for persisting audit artifacts.
        signing_key: Optional key for cryptographically signing evidence.

    Example:
        >>> github = GitHubAPI(app_id=123, private_key=key)
        >>> engine = PolicyEngine.from_rules(rules)
        >>> handler = PRHandler(github, engine)
        >>>
        >>> # Process a webhook event
        >>> result = await handler.handle_pull_request(event)
        >>> print(f"Passed: {result.passed}, Errors: {result.errors_count}")
    """

    def __init__(
        self,
        github_api: GitHubAPI,
        policy_engine: PolicyEngine | DualLayerOrchestrator,
        evidence_storage: EvidenceStorage | None = None,
        signing_key: str = "",
        evaluations_table_name: str = "",
        organizations_table_name: str = "",
        bypass_events_table_name: str = "",
        aws_region: str = "us-east-1",
        commit_signature_config: CommitSignatureConfig | None = None,
    ) -> None:
        """Initialize the PR handler with required dependencies.

        Args:
            github_api: GitHub API client for status and comment posting.
                Must be configured with valid App credentials.
            policy_engine: MergeGuide policy engine for diff evaluation.
                Accepts either a PolicyEngine (regex-only) or a
                DualLayerOrchestrator (regex + Semgrep). When an
                orchestrator is provided, scan_diff() is used for
                dual-layer analysis.
            evidence_storage: Optional storage backend for evidence artifacts.
                If None, evidence artifacts will not be persisted.
            signing_key: Optional HMAC key for signing evidence artifacts.
                Enables tamper detection for stored evidence.
            evaluations_table_name: DynamoDB table name for evaluation records.
                If empty, evaluation records will not be written.
            bypass_events_table_name: DynamoDB table name for bypass events.
                If empty, bypass enrichment is skipped.
            aws_region: AWS region for DynamoDB client.
            commit_signature_config: Optional config for commit signature
                verification. If None or mode is "off", signature checks
                are skipped.
        """
        self.github = github_api
        self.engine = policy_engine
        self.storage = evidence_storage
        self.signing_key = signing_key
        self._evaluations_table_name = evaluations_table_name
        self._organizations_table_name = organizations_table_name
        self._bypass_events_table_name = bypass_events_table_name
        self._aws_region = aws_region
        self._evaluations_table = None
        self._organizations_table = None
        self._bypass_events_table = None
        self._commit_signature_config = commit_signature_config

    async def handle_pull_request(self, event: WebhookEvent) -> PRCheckResult:
        """Process a pull_request webhook event and run policy checks.

        This is the main entry point for PR policy evaluation. It executes
        the complete check workflow:
        1. Validates the event and extracts PR metadata
        2. Posts a "pending" commit status
        3. Fetches and evaluates the PR diff
        4. Stores evidence (if configured)
        5. Posts final status and comment

        If an error occurs during evaluation, the handler posts an "error"
        status and an error comment, then re-raises the exception.

        Args:
            event: Parsed WebhookEvent from a pull_request webhook.
                Must have action in ["opened", "synchronize", "reopened"].

        Returns:
            PRCheckResult containing evaluation outcome, violation counts,
            and references to stored evidence and comments.

        Raises:
            ValueError: If the event is missing required data (installation,
                repository, or PR info) or should not be processed.
            Exception: Re-raises any exception from evaluation after posting
                error status to GitHub.
        """
        if not event.should_process_pr:
            raise ValueError(f"Event {event.event_type}.{event.action} should not be processed")

        # Extract required metadata from the webhook payload
        installation_id = event.get_installation_id()
        repo_info = event.get_repository_info()
        pr_info = event.get_pr_info()

        if not installation_id or not repo_info or not pr_info:
            raise ValueError("Missing required event data (installation, repo, or PR info)")

        owner, repo = repo_info
        pr_number = pr_info["pr_number"]
        head_sha = pr_info["head_sha"]

        # Validate all webhook-sourced fields before using as keys/paths
        try:
            validate_org_id(owner)
            validate_repo(repo)
            validate_commit_sha(head_sha)
            validate_branch(pr_info["head_ref"])
            validate_author(pr_info["author"])
            validate_pr_number(pr_number)
        except ValidationError as ve:
            logger.error(f"Webhook payload validation failed: {ve}")
            await self.github.post_status(
                installation_id=installation_id,
                owner=owner,
                repo=repo,
                sha=head_sha,
                state="error",
                description=f"Validation error: {ve}",
            )
            raise

        logger.info(f"Processing PR #{pr_number} on {owner}/{repo} @ {head_sha[:7]}")

        # Immediately post "pending" status so users see the check is running
        await self.github.post_status(
            installation_id=installation_id,
            owner=owner,
            repo=repo,
            sha=head_sha,
            state="pending",
            description="Evaluating against security policies...",
        )

        # Create an in-progress Check Run for inline annotations (DEV-866).
        # Falls back gracefully to status-only if checks:write is unavailable.
        check_run_id = await self._create_check_run(
            installation_id=installation_id,
            owner=owner,
            repo=repo,
            head_sha=head_sha,
        )

        try:
            # Fetch the PR diff content for analysis
            diff = await self.github.fetch_pr_diff(
                installation_id=installation_id,
                owner=owner,
                repo=repo,
                pr_number=pr_number,
            )

            # Resolve GitHub org name to internal org_id for multi-tenancy
            resolved_org_id = self._resolve_org_id(owner)

            # Build the evaluation context with PR metadata for policy decisions
            from mergeguide.engine.evaluator import EvaluationContext

            context = EvaluationContext(
                org_id=resolved_org_id,
                repo=repo,
                branch=pr_info["head_ref"],
                commit_sha=head_sha,
                author=pr_info["author"],
                pr_number=pr_number,
            )

            # Execute all configured policies against the diff
            # Use dual-layer scanning (regex + Semgrep) when orchestrator is available,
            # falling back to regex-only PolicyEngine.evaluate_diff() otherwise.
            import time

            from mergeguide.engine.orchestrator import DualLayerOrchestrator

            _eval_start = time.monotonic()
            if isinstance(self.engine, DualLayerOrchestrator):
                output = self.engine.scan_diff(diff, context)
            else:
                output = self.engine.evaluate_diff(diff, context)
            _eval_duration_ms = int((time.monotonic() - _eval_start) * 1000)

            # Persist evidence artifact for compliance/audit trail
            evidence_key = await self._store_evidence(
                output=output,
                context=context,
                diff_size=len(diff),
            )

            # Run commit signature verification (non-fatal — returns None if config
            # is OFF or on error). Backs SOC 2 change management evidence.
            signature_result = self._run_signature_scan(
                base_sha=pr_info.get("base_sha", ""),
                head_sha=head_sha,
                repository=f"{owner}/{repo}",
            )

            # Create SLSA provenance record linking policy output, evidence,
            # and signature verification results. Backs patent claims.
            provenance = self._create_provenance(
                output=output,
                owner=owner,
                repo=repo,
                head_sha=head_sha,
                branch=pr_info["head_ref"],
                pr_number=pr_number,
                diff_content=diff,
                evidence_key=evidence_key,
                signature_result=signature_result,
            )

            # Store provenance record alongside evidence if available
            if provenance is not None and self.storage:
                try:
                    import json

                    provenance_key = (
                        f"evidence/{context.org_id}/{context.repo or 'unknown'}"
                        f"/provenance/{output.evaluation_id}-provenance.json"
                    )
                    self.storage.store_raw(
                        key=provenance_key,
                        content=json.dumps(provenance.to_dict(), default=str),
                        content_type="application/json",
                    )
                    logger.info("Stored provenance at: %s", provenance_key)
                except Exception as e:
                    logger.warning("Failed to store provenance record: %s", e)

            # Generate SBOM if the PR touches dependency manifests (non-fatal)
            sbom_key, sbom_vulns = await self._generate_and_store_sbom(
                installation_id=installation_id,
                owner=owner,
                repo=repo,
                head_sha=head_sha,
                diff=diff,
                org_id=resolved_org_id,
                evaluation_id=output.evaluation_id,
            )

            # Run specialized scanners (vulnerability, CI/CD, IaC, license, build)
            # Non-fatal: failures logged as warnings, PR check still completes.
            scanner_results = await self._run_specialized_scanners(
                diff=diff,
                installation_id=installation_id,
                owner=owner,
                repo=repo,
                head_sha=head_sha,
            )

            # Write evaluation record to DynamoDB for dashboard visibility
            errors_count = sum(1 for v in output.violations if v.severity == "error")
            warnings_count = sum(1 for v in output.violations if v.severity == "warning")
            infos_count = sum(1 for v in output.violations if v.severity == "info")

            self._write_evaluation_record(
                evaluation_id=output.evaluation_id,
                org_id=resolved_org_id,
                repo=repo,
                pr_number=pr_number,
                commit_sha=head_sha,
                branch=pr_info["head_ref"],
                author=pr_info["author"],
                passed=output.passed,
                total_violations=len(output.violations),
                errors=errors_count,
                warnings=warnings_count,
                infos=infos_count,
                duration_ms=_eval_duration_ms,
                evidence_key=evidence_key,
                sbom_key=sbom_key,
            )

            # Best-effort bypass event enrichment (DEV-899).
            # If a bypass event exists for this commit SHA + org, mark it
            # as pushed and record the PR Gate verdict. Failures are logged
            # and swallowed — enrichment must never block the PR Gate flow.
            self._enrich_bypass_event(
                org_id=resolved_org_id,
                commit_sha=head_sha,
                passed=output.passed,
                violations=output.violations,
            )

            # DEV-995: Resolve org enforcement config for graduated blocking
            enforcement_config = self._get_enforcement_config()

            # Map evaluation result to GitHub status state using enforcement config
            conclusion = self._resolve_check_conclusion(output.violations, enforcement_config)
            state = "success" if conclusion == "success" else "failure"
            description = self._build_status_description(output)

            # Update commit status with final result
            await self.github.post_status(
                installation_id=installation_id,
                owner=owner,
                repo=repo,
                sha=head_sha,
                state=state,
                description=description,
            )

            # Complete the Check Run with inline annotations (DEV-866)
            if check_run_id is not None:
                await self._complete_check_run(
                    installation_id=installation_id,
                    owner=owner,
                    repo=repo,
                    check_run_id=check_run_id,
                    output=output,
                    scanner_results=scanner_results,
                    sbom_vulns=sbom_vulns,
                    enforcement_config=enforcement_config,
                )

            # Post detailed results as a PR comment (creates or updates existing)
            comment_id = await self._post_or_update_comment(
                installation_id=installation_id,
                owner=owner,
                repo=repo,
                pr_number=pr_number,
                output=output,
                scanner_results=scanner_results,
                sbom_key=sbom_key,
            )

            return PRCheckResult(
                passed=output.passed,
                evaluation_id=output.evaluation_id,
                violations_count=len(output.violations),
                errors_count=errors_count,
                warnings_count=warnings_count,
                evidence_key=evidence_key,
                sbom_key=sbom_key,
                comment_id=comment_id,
            )

        except Exception as e:
            # Log the full exception with traceback for debugging
            logger.exception(f"Error evaluating PR #{pr_number}: {e}")

            # Post "error" status to indicate the check itself failed (not the PR)
            await self.github.post_status(
                installation_id=installation_id,
                owner=owner,
                repo=repo,
                sha=head_sha,
                state="error",
                description="Policy check failed - see PR comment",
            )

            # Post a user-friendly error comment explaining what went wrong
            comment_body = format_error_comment(str(e))
            await self._post_or_update_comment_raw(
                installation_id=installation_id,
                owner=owner,
                repo=repo,
                pr_number=pr_number,
                body=comment_body,
            )

            # Re-raise to let the Lambda handler deal with retries/alerts
            raise

    def _get_evaluations_table(self):
        """Lazily initialize and return the DynamoDB evaluations table resource."""
        if self._evaluations_table is None:
            import boto3

            dynamodb = boto3.resource("dynamodb", region_name=self._aws_region)
            self._evaluations_table = dynamodb.Table(self._evaluations_table_name)
        return self._evaluations_table

    def _resolve_org_id(self, github_org: str) -> str:
        """Resolve a GitHub org name to the internal org_id via DynamoDB GSI.

        Queries the organizations table using the github-org-index GSI.
        Raises ValueError if no registered organization is found — this is
        a defense-in-depth measure (the webhook handler validates installation
        registration before reaching this point).

        If the organizations table is not configured (e.g., in test environments),
        falls back to using the GitHub org name directly.

        Args:
            github_org: GitHub organization login name (e.g., "MergeGuide").

        Returns:
            Internal org_id if found, or github_org if table is not configured.

        Raises:
            ValueError: If the table is configured but no organization record
                exists for the GitHub org.
            RuntimeError: If the organizations table lookup fails.
        """
        if not self._organizations_table_name:
            return github_org

        try:
            if self._organizations_table is None:
                import boto3

                dynamodb = boto3.resource("dynamodb", region_name=self._aws_region)
                self._organizations_table = dynamodb.Table(self._organizations_table_name)

            from boto3.dynamodb.conditions import Key

            response = self._organizations_table.query(
                IndexName="github-org-index",
                KeyConditionExpression=Key("github_org").eq(github_org),
                Limit=1,
            )

            items = response.get("Items", [])
            if items:
                org_id = items[0].get("org_id", github_org)
                logger.info(f"Resolved GitHub org '{github_org}' to org_id '{org_id}'")
                # Cache the org item for enforcement config lookup (DEV-995)
                self._resolved_org_item = items[0]
                return org_id

            raise ValueError(f"No registered organization found for GitHub org '{github_org}'")

        except ValueError:
            raise
        except Exception as e:
            raise RuntimeError(f"Failed to resolve org_id for '{github_org}': {e}") from e

    def _get_enforcement_config(self) -> dict[str, Any]:
        """Get the org's enforcement configuration from the cached org item.

        Returns the enforcement config from org settings, or defaults to
        strict mode if not configured. (DEV-995)

        Returns:
            Dict with 'mode' and optional 'severityOverrides' keys.
        """
        default_config: dict[str, Any] = {"mode": "strict"}

        org_item = getattr(self, "_resolved_org_item", None)
        if not org_item:
            return default_config

        settings = org_item.get("settings", {})
        if isinstance(settings, str):
            try:
                import json

                settings = json.loads(settings)
            except (json.JSONDecodeError, TypeError):
                return default_config

        enforcement = settings.get("enforcement_config")
        if not enforcement or not isinstance(enforcement, dict):
            return default_config

        return enforcement

    @staticmethod
    def _resolve_check_conclusion(
        violations: list[Any],
        enforcement_config: dict[str, Any],
    ) -> str:
        """Determine the GitHub check conclusion based on enforcement config.

        DEV-995: Graduated enforcement — the check conclusion varies by the
        highest-severity violation and the org's configured enforcement actions.

        Args:
            violations: List of violation objects with .severity attribute.
            enforcement_config: Org enforcement config dict with mode and
                optional severityOverrides.

        Returns:
            GitHub check conclusion: "success", "neutral", or "failure".
        """
        if not violations:
            return "success"

        mode = enforcement_config.get("mode", "strict")

        # Advisory mode: always success (annotations only)
        if mode == "advisory":
            return "success"

        # Strict mode: current behavior — any violation above threshold = failure
        if mode == "strict":
            has_errors = any(getattr(v, "severity", None) == "error" for v in violations)
            return "failure" if has_errors else "success"

        # Graduated mode: apply per-severity actions
        # Default graduated presets
        presets: dict[str, str] = {
            "critical": "hard_block",
            "high": "hard_block",
            "medium": "soft_block",
            "low": "advisory",
            "info": "advisory",
            # Map "error" severity to "critical" action for compatibility
            "error": "hard_block",
            "warning": "soft_block",
        }

        # Apply custom overrides if present
        overrides = enforcement_config.get("severityOverrides")
        if overrides and isinstance(overrides, dict):
            presets.update(overrides)

        worst_action = "advisory"  # least severe
        action_rank = {"advisory": 0, "soft_block": 1, "hard_block": 2}

        for v in violations:
            severity = getattr(v, "severity", "info")
            action = presets.get(severity, "advisory")
            if action_rank.get(action, 0) > action_rank.get(worst_action, 0):
                worst_action = action

        if worst_action == "hard_block":
            return "failure"
        elif worst_action == "soft_block":
            return "neutral"
        else:
            return "success"

    def _write_evaluation_record(
        self,
        *,
        evaluation_id: str,
        org_id: str,
        repo: str,
        pr_number: int,
        commit_sha: str,
        branch: str,
        author: str,
        passed: bool,
        total_violations: int,
        errors: int,
        warnings: int,
        infos: int,
        duration_ms: int,
        evidence_key: str | None,
        sbom_key: str | None = None,
    ) -> None:
        """Write an evaluation record to the evaluations DynamoDB table.

        Write failures are non-fatal — logged as warnings but do not
        fail the webhook response.
        """
        if not self._evaluations_table_name:
            return

        try:
            from datetime import UTC, datetime

            created_at = datetime.now(UTC).isoformat().replace("+00:00", "Z")
            table = self._get_evaluations_table()

            item = {
                "pk": f"ORG#{org_id}#REPO#{repo}",
                "sk": f"EVAL#{created_at}#{evaluation_id}",
                "eval_id": evaluation_id,
                "org_id": org_id,
                "repo": repo,
                "pr_number": pr_number,
                "commit_sha": commit_sha,
                "branch": branch,
                "author": author,
                "source": "pr_gate",
                "passed": passed,
                "total_violations": total_violations,
                "errors": errors,
                "warnings": warnings,
                "infos": infos,
                "duration_ms": duration_ms,
                "evidence_key": evidence_key or "",
                "sbom_key": sbom_key or "",
                "created_at": created_at,
                # DEV-1135: Processing pipeline visibility — PR Gate evaluations
                # are written after processing completes, so status is "complete".
                "processing_status": "complete",
            }

            table.put_item(Item=item)
            logger.info(f"Wrote evaluation record {evaluation_id} to DynamoDB")

        except Exception as e:
            logger.warning(f"Failed to write evaluation record: {e}")

    def _get_bypass_events_table(self):
        """Lazily initialize and return the DynamoDB bypass-events table resource."""
        if self._bypass_events_table is None:
            import boto3

            dynamodb = boto3.resource("dynamodb", region_name=self._aws_region)
            self._bypass_events_table = dynamodb.Table(self._bypass_events_table_name)
        return self._bypass_events_table

    def _enrich_bypass_event(
        self,
        *,
        org_id: str,
        commit_sha: str,
        passed: bool,
        violations: list,
    ) -> None:
        """Best-effort enrichment of a bypass event after push (DEV-899).

        Queries the bypass-events table for a record matching this org_id +
        commit_sha. If found, updates the record with pushed=true,
        push_timestamp, pr_gate_verdict, and pr_gate_violations count.

        This is a best-effort operation: any failure is logged and swallowed.
        The PR Gate flow must never be blocked or delayed by bypass enrichment.

        Args:
            org_id: Internal organization ID.
            commit_sha: The commit SHA being evaluated.
            passed: Whether the PR Gate check passed.
            violations: List of violation objects from the evaluation.
        """
        if not self._bypass_events_table_name:
            return

        try:
            from datetime import UTC, datetime

            from boto3.dynamodb.conditions import Attr, Key

            table = self._get_bypass_events_table()

            # Query the table by PK (ORG#{org_id}), filter by commit_sha
            response = table.query(
                KeyConditionExpression=Key("pk").eq(f"ORG#{org_id}"),
                FilterExpression=Attr("commit_sha").eq(commit_sha),
                Limit=10,
                ScanIndexForward=False,
            )

            items = response.get("Items", [])
            if not items:
                logger.debug(
                    "No bypass event found for org=%s commit=%s — skipping enrichment",
                    org_id,
                    commit_sha[:7],
                )
                return

            item = items[0]
            now = datetime.now(UTC).isoformat().replace("+00:00", "Z")

            # Calculate time_to_push from original bypass timestamp
            bypass_ts = item.get("timestamp", "")
            time_to_push_seconds = 0
            if bypass_ts:
                try:
                    bypass_dt = datetime.fromisoformat(bypass_ts.replace("Z", "+00:00"))
                    now_dt = datetime.now(UTC)
                    time_to_push_seconds = int((now_dt - bypass_dt).total_seconds())
                except (ValueError, TypeError):
                    pass

            verdict = "pass" if passed else "fail"

            # Serialize all violations as an array of dicts for DynamoDB.
            # Each violation includes available fields: rule_id, file_path,
            # severity, message, line_no.
            violations_list = []
            for v in violations:
                viol_dict: dict[str, Any] = {}
                for attr in ("rule_id", "file_path", "severity", "message", "line_no"):
                    val = getattr(v, attr, None)
                    if val is not None:
                        viol_dict[attr] = val
                if viol_dict:
                    violations_list.append(viol_dict)

            update_expr = (
                "SET pushed = :pushed, "
                "push_timestamp = :pushTs, "
                "time_to_push_seconds = :ttps, "
                "pr_gate_verdict = :verdict, "
                "pr_gate_violations = :violations, "
                "pr_gate_violations_count = :violCount"
            )
            expr_values: dict[str, Any] = {
                ":pushed": True,
                ":pushTs": now,
                ":ttps": time_to_push_seconds,
                ":verdict": verdict,
                ":violations": violations_list,
                ":violCount": len(violations_list),
            }

            table.update_item(
                Key={"pk": item["pk"], "sk": item["sk"]},
                UpdateExpression=update_expr,
                ExpressionAttributeValues=expr_values,
            )

            logger.info(
                "Enriched bypass event for org=%s commit=%s: verdict=%s, violations=%d",
                org_id,
                commit_sha[:7],
                verdict,
                len(violations_list),
            )

        except Exception as e:
            logger.warning("Bypass event enrichment failed (non-fatal): %s", e)

    async def _store_evidence(
        self,
        output: PolicyOutput,
        context: EvaluationContext,
        diff_size: int,
    ) -> str | None:
        """Store an evidence artifact for the evaluation in configured storage.

        Evidence artifacts provide an immutable audit trail of policy evaluations.
        They can be signed for tamper detection and stored in S3 or other backends.

        The artifact includes:
        - Evaluation timestamp and ID
        - All violations found with file/line info
        - Metadata about the PR and author
        - List of policies that were applied

        Note: The full diff content is NOT stored in the artifact for privacy
        and storage efficiency reasons.

        Args:
            output: Policy evaluation output containing violations and metadata.
            context: Evaluation context with PR and repository information.
            diff_size: Size of the diff in bytes (for source metadata).

        Returns:
            Storage key (e.g., S3 object key) if successfully stored,
            None if storage is not configured or if storage fails.
        """
        if not self.storage:
            return None

        try:
            from mergeguide.evidence.artifact import EvidenceArtifact, SourceInfo

            # Build source metadata for the artifact
            source = SourceInfo(
                type="diff",
                location=f"{context.repo}#{context.pr_number}",
                ref=context.commit_sha,
                size_bytes=diff_size,
            )

            # Create the evidence artifact from evaluation output
            # Resolve rule list from either PolicyEngine or DualLayerOrchestrator
            from mergeguide.engine.orchestrator import DualLayerOrchestrator

            if isinstance(self.engine, DualLayerOrchestrator):
                _rule_engine = self.engine.regex_engine.rule_engine
            else:
                _rule_engine = self.engine.rule_engine

            artifact = EvidenceArtifact.from_output(
                output=output,
                source=source,
                context=context,
                input_content="",  # Don't store full diff in artifact for privacy
                policies_used=[r.id for r in _rule_engine.rules],
            )

            # Optionally sign the artifact for tamper detection
            if self.signing_key:
                artifact.sign(self.signing_key)

            # Persist to configured storage backend
            evidence_key = self.storage.store(
                artifact=artifact,
                org_id=context.org_id,
                repo=context.repo or "unknown",
            )

            logger.info(f"Stored evidence at: {evidence_key}")
            return evidence_key

        except Exception as e:
            # Log but don't fail the check if evidence storage fails
            logger.warning(f"Failed to store evidence: {e}")
            return None

    async def _generate_and_store_sbom(
        self,
        *,
        installation_id: int,
        owner: str,
        repo: str,
        head_sha: str,
        diff: str,
        org_id: str,
        evaluation_id: str,
    ) -> tuple[str | None, list[SbomVuln]]:
        """Generate an SBOM if the PR diff contains dependency manifest changes.

        Scans the diff for manifest file paths. If any are found, fetches the
        full file content at head_sha, generates a CycloneDX SBOM, and stores
        it in S3 via EvidenceStorage.store_raw().

        This operation is entirely non-fatal: any failure logs a warning and
        returns (None, []) without affecting the PR check outcome.

        Args:
            installation_id: GitHub App installation ID for file content fetch.
            owner: Repository owner.
            repo: Repository name.
            head_sha: Head commit SHA (used to fetch file content at correct ref).
            diff: Raw unified diff string from the PR.
            org_id: Internal organization ID for S3 key partitioning.
            evaluation_id: Evaluation ID for S3 key uniqueness.

        Returns:
            Tuple of (S3 key, list of high/critical SbomVulns). Key is None if
            no manifests were changed, storage is not configured, or an error
            occurred.
        """
        if not self.storage:
            return None, []

        try:
            # Extract file paths from the diff to find manifest changes
            manifest_paths = self._extract_manifest_paths(diff)
            if not manifest_paths:
                return None, []

            logger.info(
                "PR touches %d manifest file(s), generating SBOM: %s",
                len(manifest_paths),
                ", ".join(manifest_paths),
            )

            # Fetch full content of each manifest file at head_sha
            manifest_files: dict[str, str] = {}
            for path in manifest_paths:
                content = await self.github.get_file_content(
                    installation_id=installation_id,
                    owner=owner,
                    repo=repo,
                    path=path,
                    ref=head_sha,
                )
                if content is not None:
                    manifest_files[path] = content

            if not manifest_files:
                logger.info("No manifest file content could be fetched, skipping SBOM")
                return None, []

            # Determine SBOM format from environment variable
            import json
            import os

            from mergeguide.sbom.generators import SBOMFormat, generate_sbom

            format_str = os.environ.get("SBOM_FORMAT", "cyclonedx-json")
            try:
                sbom_format = SBOMFormat(format_str)
            except ValueError:
                logger.warning(
                    "Invalid SBOM_FORMAT '%s', defaulting to cyclonedx-json",
                    format_str,
                )
                sbom_format = SBOMFormat.CYCLONEDX_JSON

            # Map format to file extension for the S3 key
            format_extensions = {
                SBOMFormat.CYCLONEDX_JSON: ".cdx.json",
                SBOMFormat.CYCLONEDX_XML: ".cdx.xml",
                SBOMFormat.SPDX_JSON: ".spdx.json",
                SBOMFormat.SPDX_TAG_VALUE: ".spdx",
            }
            ext = format_extensions.get(sbom_format, ".cdx.json")

            # Generate SBOM
            sbom_content = generate_sbom(
                files=manifest_files,
                format=sbom_format,
                name=repo,
                version=head_sha[:8],
            )

            # Store at deterministic S3 key: sbom/{owner}/{repo}/{head_sha}.ext
            sbom_key = f"sbom/{owner}/{repo}/{head_sha}{ext}"

            content_type = "application/json"
            if sbom_format == SBOMFormat.CYCLONEDX_XML:
                content_type = "application/xml"
            elif sbom_format == SBOMFormat.SPDX_TAG_VALUE:
                content_type = "text/plain"

            self.storage.store_raw(
                key=sbom_key,
                content=sbom_content,
                content_type=content_type,
            )

            logger.info("Stored SBOM at: %s", sbom_key)

            # Parse SBOM for high/critical vulnerabilities (DEV-872)
            try:
                sbom_dict = json.loads(sbom_content)
                high_vulns = _parse_sbom_for_vulns(sbom_dict)
                critical_vulns = [v for v in high_vulns if v.cvss_score >= 9.0]
                high_only_vulns = [v for v in high_vulns if v.cvss_score < 9.0]

                # Structured log line for CloudWatch metric filter
                logger.info(
                    "sbom_scan_complete",
                    extra={
                        "event": "sbom_critical_found",
                        "count": len(critical_vulns),
                        "high_count": len(high_only_vulns),
                        "sbom_key": sbom_key,
                    },
                )

                if critical_vulns:
                    logger.warning(
                        "SBOM contains %d critical vulnerabilities (CVSS >= 9.0): %s",
                        len(critical_vulns),
                        ", ".join(v.id for v in critical_vulns),
                    )
                elif high_only_vulns:
                    logger.info(
                        "SBOM contains %d high vulnerabilities (7.0 <= CVSS < 9.0)",
                        len(high_only_vulns),
                    )
            except (json.JSONDecodeError, TypeError):
                logger.warning("Could not parse SBOM content for vuln analysis")
                return sbom_key, []

            return sbom_key, high_vulns

        except Exception as e:
            logger.warning("SBOM generation failed (non-fatal): %s", e)
            return None, []

    @staticmethod
    def _extract_manifest_paths(diff: str) -> list[str]:
        """Extract manifest file paths from a unified diff.

        Parses 'diff --git a/path b/path' headers in the diff to find
        files that match recognized dependency manifest patterns.

        Args:
            diff: Raw unified diff string.

        Returns:
            List of unique manifest file paths found in the diff.
        """
        import re

        paths: list[str] = []
        seen: set[str] = set()

        for match in re.finditer(r"^diff --git a/(.+?) b/(.+?)$", diff, re.MULTILINE):
            # Use the 'b/' path (the destination/new file path)
            filepath = match.group(2)
            if filepath not in seen and _is_manifest_file(filepath):
                paths.append(filepath)
                seen.add(filepath)

        return paths

    # -----------------------------------------------------------------
    # Check Runs — inline annotations (DEV-866)
    # -----------------------------------------------------------------

    #: GitHub limits annotations to 50 per API call.
    _CHECK_RUN_ANNOTATION_BATCH_SIZE = 50

    #: Maximum length for annotation message text (GitHub limit is 64 KB;
    #: we cap at 1000 chars for readability).
    _ANNOTATION_MESSAGE_MAX_LEN = 1000

    @staticmethod
    def _map_severity_to_annotation_level(severity: str) -> str:
        """Map a violation severity to a GitHub annotation level.

        Args:
            severity: One of ``error``, ``warning``, ``info``.

        Returns:
            One of ``failure``, ``warning``, ``notice``.
        """
        mapping = {
            "error": "failure",
            "warning": "warning",
            "info": "notice",
        }
        return mapping.get(severity, "warning")

    def _build_annotations(
        self,
        output: PolicyOutput,
        scanner_results: SpecializedScannerResults | None = None,
    ) -> list[dict[str, Any]]:
        """Build GitHub Check Run annotations from violations and scanner findings.

        Each violation becomes one annotation. Scanner findings that include
        file/line information are also converted to annotations.

        Args:
            output: Policy evaluation output containing violations.
            scanner_results: Optional specialized scanner results.

        Returns:
            List of annotation dicts ready for the Check Runs API.
        """
        annotations: list[dict[str, Any]] = []

        # Convert policy violations to annotations
        for v in output.violations:
            line_no = getattr(v, "line_no", None) or 1
            message = getattr(v, "message", str(v))
            if len(message) > self._ANNOTATION_MESSAGE_MAX_LEN:
                message = message[: self._ANNOTATION_MESSAGE_MAX_LEN - 3] + "..."

            annotations.append(
                {
                    "path": getattr(v, "file_path", "unknown"),
                    "start_line": line_no,
                    "end_line": line_no,
                    "annotation_level": self._map_severity_to_annotation_level(
                        getattr(v, "severity", "warning")
                    ),
                    "message": message,
                    "title": getattr(v, "rule_id", "policy-violation"),
                }
            )

        # Convert specialized scanner findings to annotations
        if scanner_results:
            for scanner_name in ("vulnerability", "cicd", "iac", "license", "build"):
                result_dict = getattr(scanner_results, scanner_name, None)
                if not result_dict:
                    continue
                findings = result_dict.get("findings", result_dict.get("issues", []))
                for f in findings:
                    file_path = f.get("file_path") or f.get("file") or "unknown"
                    line_no = f.get("line_no") or f.get("line") or 1
                    severity = f.get("severity", "warning")
                    message = f.get("message", f.get("description", ""))
                    if len(message) > self._ANNOTATION_MESSAGE_MAX_LEN:
                        message = message[: self._ANNOTATION_MESSAGE_MAX_LEN - 3] + "..."

                    annotations.append(
                        {
                            "path": file_path,
                            "start_line": line_no,
                            "end_line": line_no,
                            "annotation_level": self._map_severity_to_annotation_level(severity),
                            "message": message,
                            "title": f.get("rule_id", f.get("rule_name", scanner_name)),
                        }
                    )

        return annotations

    async def _create_check_run(
        self,
        installation_id: int,
        owner: str,
        repo: str,
        head_sha: str,
    ) -> int | None:
        """Create an in-progress Check Run, returning the ID or None on permission error.

        Args:
            installation_id: GitHub App installation ID.
            owner: Repository owner.
            repo: Repository name.
            head_sha: Commit SHA for the check.

        Returns:
            check_run_id if created successfully, None if ``checks:write``
            is not available.
        """
        try:
            return await self.github.create_check_run(
                installation_id=installation_id,
                owner=owner,
                repo=repo,
                name="MergeGuide Policy Check",
                head_sha=head_sha,
                status="in_progress",
            )
        except GitHubPermissionError:
            logger.warning(
                "checks:write not available for %s/%s — falling back to status-only mode",
                owner,
                repo,
            )
            return None

    async def _complete_check_run(
        self,
        installation_id: int,
        owner: str,
        repo: str,
        check_run_id: int,
        output: PolicyOutput,
        scanner_results: SpecializedScannerResults | None = None,
        sbom_vulns: list[SbomVuln] | None = None,
        enforcement_config: dict[str, Any] | None = None,
    ) -> None:
        """Complete a Check Run with conclusion and inline annotations.

        Annotations are batched in groups of 50 (GitHub's per-request limit).
        The first batch is sent with the completion PATCH; subsequent batches
        are sent as additional PATCH calls that keep the status ``completed``.

        SBOM vulnerability annotations (DEV-872):
        - Critical (CVSS >= 9.0): ``failure`` level, forces conclusion to ``failure``
        - High (7.0 <= CVSS < 9.0): ``warning`` level, does not change conclusion

        DEV-995: Graduated enforcement — conclusion is determined by enforcement
        config rather than a binary pass/fail.

        Args:
            installation_id: GitHub App installation ID.
            owner: Repository owner.
            repo: Repository name.
            check_run_id: ID of the check run to complete.
            output: Policy evaluation output.
            scanner_results: Optional specialized scanner results.
            sbom_vulns: Optional list of high/critical SBOM vulnerabilities.
            enforcement_config: Optional org enforcement configuration.
        """
        # DEV-995: Use graduated enforcement to determine conclusion
        ec = enforcement_config or {"mode": "strict"}
        conclusion = self._resolve_check_conclusion(output.violations, ec)
        annotations = self._build_annotations(output, scanner_results)

        # Append SBOM vulnerability annotations (DEV-872)
        if sbom_vulns:
            has_critical = False
            for vuln in sbom_vulns:
                if vuln.cvss_score >= 9.0:
                    level = "failure"
                    has_critical = True
                else:
                    level = "warning"

                desc = vuln.description or "No description"
                if len(desc) > self._ANNOTATION_MESSAGE_MAX_LEN:
                    desc = desc[: self._ANNOTATION_MESSAGE_MAX_LEN - 3] + "..."

                annotations.append(
                    {
                        "path": "SBOM",
                        "start_line": 1,
                        "end_line": 1,
                        "annotation_level": level,
                        "message": (
                            f"[{vuln.id}] CVSS {vuln.cvss_score:.1f} ({vuln.severity}): {desc}"
                        ),
                        "title": f"sbom-vuln-{vuln.id}",
                    }
                )

            # Critical SBOM vulns force check conclusion to failure
            if has_critical:
                conclusion = "failure"

        errors = sum(1 for v in output.violations if v.severity == "error")
        warnings = sum(1 for v in output.violations if v.severity == "warning")

        if conclusion == "success" and not output.violations:
            title = "Policy Check Passed"
            summary = f"All checks passed ({len(output.files_checked)} files)"
        elif conclusion == "success" and output.violations:
            # DEV-995: Advisory mode — violations found but not blocking
            title = "Policy Check Passed (Advisory)"
            parts = []
            if errors:
                parts.append(f"{errors} error{'s' if errors != 1 else ''}")
            if warnings:
                parts.append(f"{warnings} warning{'s' if warnings != 1 else ''}")
            file_count = len(output.files_checked)
            plural = "s" if file_count != 1 else ""
            summary = (
                f"Found {', '.join(parts)} across {file_count} file{plural}"
                " (advisory mode — not blocking)"
            )
        elif conclusion == "neutral":
            # DEV-995: Graduated mode — soft block
            title = "Policy Check — Review Required"
            parts = []
            if errors:
                parts.append(f"{errors} error{'s' if errors != 1 else ''}")
            if warnings:
                parts.append(f"{warnings} warning{'s' if warnings != 1 else ''}")
            file_count = len(output.files_checked)
            plural = "s" if file_count != 1 else ""
            summary = f"Found {', '.join(parts)} across {file_count} file{plural} (review required)"
        else:
            parts = []
            if errors:
                parts.append(f"{errors} error{'s' if errors != 1 else ''}")
            if warnings:
                parts.append(f"{warnings} warning{'s' if warnings != 1 else ''}")
            file_count = len(output.files_checked)
            title = "Policy Check Failed"
            plural = "s" if file_count != 1 else ""
            summary = f"Found {', '.join(parts)} across {file_count} file{plural}"

        # Send annotations in batches of 50
        batch_size = self._CHECK_RUN_ANNOTATION_BATCH_SIZE
        n = max(len(annotations), 1)
        batches = [annotations[i : i + batch_size] for i in range(0, n, batch_size)]
        if not batches:
            batches = [[]]

        for idx, batch in enumerate(batches):
            check_output: dict[str, Any] = {
                "title": title,
                "summary": summary,
            }
            if batch:
                check_output["annotations"] = batch

            try:
                await self.github.update_check_run(
                    installation_id=installation_id,
                    owner=owner,
                    repo=repo,
                    check_run_id=check_run_id,
                    status="completed" if idx == len(batches) - 1 else "in_progress",
                    conclusion=conclusion if idx == len(batches) - 1 else None,
                    output=check_output,
                )
            except GitHubPermissionError:
                logger.warning("checks:write revoked mid-flight — annotations incomplete")
                return
            except Exception as exc:
                logger.warning("Failed to update check run batch %d: %s", idx, exc)
                return

    def _build_status_description(self, output: PolicyOutput) -> str:
        """Build a concise description string for the GitHub commit status.

        GitHub status descriptions are limited to ~140 characters, so this
        method creates a brief summary suitable for display in the PR UI.

        Args:
            output: Policy evaluation output to summarize.

        Returns:
            A short description string like "All checks passed (5 files)"
            or "Found 2 errors, 3 warnings".
        """
        if output.passed:
            return f"All checks passed ({len(output.files_checked)} files)"

        # Count violations by severity level
        errors = sum(1 for v in output.violations if v.severity == "error")
        warnings = sum(1 for v in output.violations if v.severity == "warning")

        # Build human-readable summary with proper pluralization
        parts = []
        if errors:
            parts.append(f"{errors} error{'s' if errors != 1 else ''}")
        if warnings:
            parts.append(f"{warnings} warning{'s' if warnings != 1 else ''}")

        return f"Found {', '.join(parts)}"

    async def _run_specialized_scanners(
        self,
        diff: str,
        installation_id: int,
        owner: str,
        repo: str,
        head_sha: str,
    ) -> SpecializedScannerResults:
        """Run specialized scanners (vuln, CI/CD, IaC, license, build) on PR diff.

        Parses the diff to identify relevant files, fetches full content where
        needed, and invokes each applicable scanner. All failures are non-fatal.

        Args:
            diff: Raw unified diff content.
            installation_id: GitHub App installation ID.
            owner: Repository owner.
            repo: Repository name.
            head_sha: Head commit SHA for content fetching.

        Returns:
            SpecializedScannerResults with all scanner outputs.
        """
        try:
            from mergeguide.engine.parser import DiffParser

            diff_files = DiffParser().parse(diff)

            return await run_specialized_scanners(
                diff_files=diff_files,
                github_api=self.github,
                installation_id=installation_id,
                owner=owner,
                repo=repo,
                head_sha=head_sha,
                repository=f"{owner}/{repo}",
            )
        except Exception as e:
            logger.warning("Specialized scanner orchestration failed: %s", e)
            return SpecializedScannerResults()

    async def _post_or_update_comment(
        self,
        installation_id: int,
        owner: str,
        repo: str,
        pr_number: int,
        output: PolicyOutput,
        scanner_results: SpecializedScannerResults | None = None,
        sbom_key: str | None = None,
    ) -> int | None:
        """Post or update the bot's PR comment with evaluation results.

        Formats the policy output into a Markdown comment and delegates
        to _post_or_update_comment_raw for the actual GitHub API calls.

        Args:
            installation_id: GitHub App installation ID for API auth.
            owner: Repository owner (org or user).
            repo: Repository name.
            pr_number: Pull request number to comment on.
            output: Policy evaluation output to format into the comment.
            scanner_results: Optional specialized scanner results to append.
            sbom_key: Optional S3 key of the generated SBOM artifact.

        Returns:
            The GitHub comment ID if successful, None if the operation failed.
        """
        body = format_pr_comment(output, output.evaluation_id)

        # Append specialized scanner sections if there are findings
        if scanner_results and scanner_results.has_findings:
            scanner_sections = format_scanner_sections(scanner_results)
            if scanner_sections:
                body = body + "\n" + scanner_sections

        # Append SBOM reference if one was generated
        if sbom_key:
            body = body + f"\n\n---\n**SBOM:** `{sbom_key}`"

        return await self._post_or_update_comment_raw(
            installation_id=installation_id,
            owner=owner,
            repo=repo,
            pr_number=pr_number,
            body=body,
        )

    async def _post_or_update_comment_raw(
        self,
        installation_id: int,
        owner: str,
        repo: str,
        pr_number: int,
        body: str,
    ) -> int | None:
        """Post or update a PR comment with raw body content.

        This method implements idempotent commenting by looking for an existing
        bot comment (identified by COMMENT_MARKER) and updating it rather than
        creating duplicates. This keeps the PR clean even after multiple
        force-pushes or re-runs.

        Args:
            installation_id: GitHub App installation ID for API auth.
            owner: Repository owner (org or user).
            repo: Repository name.
            pr_number: Pull request number to comment on.
            body: Raw Markdown body content for the comment.

        Returns:
            The GitHub comment ID (existing or new) if successful,
            None if the operation failed.
        """
        try:
            # Search for existing bot comment to update (avoids duplicates)
            existing_id = await self.github.find_bot_comment(
                installation_id=installation_id,
                owner=owner,
                repo=repo,
                pr_number=pr_number,
                marker=COMMENT_MARKER,
            )

            if existing_id:
                # Update existing comment in place
                await self.github.update_comment(
                    installation_id=installation_id,
                    owner=owner,
                    repo=repo,
                    comment_id=existing_id,
                    body=body,
                )
                return existing_id
            else:
                # Create new comment
                return await self.github.post_comment(
                    installation_id=installation_id,
                    owner=owner,
                    repo=repo,
                    pr_number=pr_number,
                    body=body,
                )

        except Exception as e:
            # Log but don't fail the check if commenting fails
            logger.warning(f"Failed to post/update comment: {e}")
            return None

    def _run_signature_scan(
        self,
        base_sha: str,
        head_sha: str,
        repository: str,
    ) -> SignatureScanResult | None:
        """Run commit signature verification if configured.

        Args:
            base_sha: Base commit SHA for the range.
            head_sha: Head commit SHA for the range.
            repository: Full repository name (owner/repo).

        Returns:
            SignatureScanResult if scanning was performed, None if skipped
            (mode is off or config is None) or if an error occurred.
        """
        config = self._commit_signature_config
        if config is None or config.mode == SignaturePolicyMode.OFF:
            return None

        try:
            scanner = CommitSignatureScanner(
                require_trusted=config.require_trusted,
                allowed_algorithms=config.allowed_algorithms,
            )
            return scanner.scan_range(
                base_sha=base_sha,
                head_sha=head_sha,
                repository=repository,
            )
        except Exception as e:
            logger.warning(f"Signature scan failed: {e}")
            return None

    def _create_provenance(
        self,
        output: PolicyOutput,
        owner: str,
        repo: str,
        head_sha: str,
        branch: str,
        pr_number: int,
        diff_content: str,
        evidence_key: str | None,
        signature_result: SignatureScanResult | None,
    ) -> ProvenanceRecord | None:
        """Create a provenance record for the evaluation.

        Args:
            output: Policy evaluation output.
            owner: Repository owner.
            repo: Repository name.
            head_sha: Head commit SHA.
            branch: Branch name.
            pr_number: PR number.
            diff_content: Diff content for hashing.
            evidence_key: S3 key for evidence artifact.
            signature_result: Optional signature scan result.

        Returns:
            Finalized ProvenanceRecord, or None if creation failed.
        """
        try:
            from mergeguide.evidence.provenance import ArtifactType, BuildProvenance

            prov = BuildProvenance.create(
                repository=f"{owner}/{repo}",
                commit_sha=head_sha,
                branch=branch,
                pr_number=pr_number,
                evaluation_id=output.evaluation_id,
                diff_content=diff_content,
            )

            # Add policy output artifact
            policy_json = output.to_json()
            prov.add_artifact(
                name="policy-output.json",
                digest=f"sha256:{__import__('hashlib').sha256(policy_json.encode()).hexdigest()}",
                artifact_type=ArtifactType.POLICY_OUTPUT,
                size_bytes=len(policy_json.encode()),
            )

            # Add evidence artifact if stored
            if evidence_key:
                prov.add_evidence_artifact(
                    evidence_json=policy_json,
                    evidence_key=evidence_key,
                )

            # Add signature report if available
            if signature_result is not None:
                import json

                sig_json = json.dumps(signature_result.to_dict())
                prov.add_signature_report(sig_json)

            return prov.finalize()

        except Exception as e:
            logger.warning(f"Failed to create provenance record: {e}")
            return None


class InstallationHandler:
    """Handles GitHub App installation lifecycle events.

    This handler processes installation webhooks to track which organizations
    and repositories have installed the MergeGuide GitHub App. Installation
    state is persisted to DynamoDB for later lookup during PR processing.

    Supported installation actions:
        - created: New installation, store in DynamoDB as active
        - deleted: Mark installation as deleted (soft delete)
        - suspended: Mark installation as suspended (billing/admin action)
        - unsuspended: Reactivate a suspended installation

    DynamoDB table schema:
        - pk: INSTALLATION#{installation_id}
        - sk: CONFIG
        - status: active | suspended | deleted
        - account_login: GitHub org/user login
        - created_at: ISO 8601 timestamp

    Attributes:
        table_name: Name of the DynamoDB table for installation records.
        region: AWS region where the DynamoDB table is located.

    Example:
        >>> handler = InstallationHandler("mergeguide-installations")
        >>> result = handler.handle_installation(event)
        >>> print(result)
        {'action': 'created', 'installation_id': 12345}
    """

    def __init__(self, installations_table_name: str, region: str = "us-east-1") -> None:
        """Initialize the installation handler with DynamoDB configuration.

        Args:
            installations_table_name: Name of the DynamoDB table for storing
                installation records. Table must exist with pk/sk key schema.
            region: AWS region for DynamoDB. Defaults to us-east-1.
        """
        self.table_name = installations_table_name
        self.region = region
        self._table = None

    def _get_table(self):
        """Lazily initialize and return the DynamoDB table resource.

        Uses lazy loading to avoid AWS SDK initialization overhead when
        the handler is constructed but not used (e.g., during import).

        Returns:
            boto3 DynamoDB Table resource.
        """
        if self._table is None:
            import boto3

            dynamodb = boto3.resource("dynamodb", region_name=self.region)
            self._table = dynamodb.Table(self.table_name)
        return self._table

    def handle_installation(self, event: WebhookEvent) -> dict[str, Any]:
        """Route an installation webhook event to the appropriate handler.

        Dispatches to _handle_created, _handle_deleted, _handle_suspended,
        or _handle_unsuspended based on the event action.

        Args:
            event: Parsed WebhookEvent from an installation webhook.

        Returns:
            Dict with 'action' key and 'installation_id' or 'status'.

        Raises:
            ValueError: If the event is missing the installation ID.
        """
        action = event.action
        installation = event.payload.get("installation", {})
        installation_id = installation.get("id")

        if not installation_id:
            raise ValueError("Missing installation ID in event")

        if action == "created":
            return self._handle_created(installation)
        elif action == "deleted":
            return self._handle_deleted(installation)
        elif action == "suspended":
            return self._handle_suspended(installation)
        elif action == "unsuspended":
            return self._handle_unsuspended(installation)
        else:
            logger.info(f"Unhandled installation action: {action}")
            return {"action": action, "status": "ignored"}

    def _handle_created(self, installation: dict) -> dict[str, Any]:
        """Handle a new GitHub App installation.

        Creates a new record in DynamoDB with the installation metadata
        and sets status to 'active'.

        Args:
            installation: Installation object from the webhook payload.

        Returns:
            Dict with action='created' and the installation_id.
        """
        from datetime import UTC, datetime

        table = self._get_table()

        # Build the DynamoDB item with installation metadata
        item = {
            "pk": f"INSTALLATION#{installation['id']}",
            "sk": "CONFIG",
            "installation_id": installation["id"],
            "account_login": installation.get("account", {}).get("login", ""),
            "account_type": installation.get("account", {}).get("type", ""),
            "created_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
            "status": "active",
            "app_id": installation.get("app_id"),
            "target_type": installation.get("target_type", ""),
        }

        table.put_item(Item=item)
        logger.info(f"Created installation {installation['id']}")

        return {"action": "created", "installation_id": installation["id"]}

    def _handle_deleted(self, installation: dict) -> dict[str, Any]:
        """Handle GitHub App uninstallation.

        Performs a soft delete by updating the status to 'deleted' and
        recording the deletion timestamp. The record is retained for
        audit purposes.

        Args:
            installation: Installation object from the webhook payload.

        Returns:
            Dict with action='deleted' and the installation_id.
        """
        table = self._get_table()

        # Soft delete: update status rather than removing the record
        table.update_item(
            Key={
                "pk": f"INSTALLATION#{installation['id']}",
                "sk": "CONFIG",
            },
            UpdateExpression="SET #status = :status, deleted_at = :deleted_at",
            ExpressionAttributeNames={"#status": "status"},
            ExpressionAttributeValues={
                ":status": "deleted",
                ":deleted_at": __import__("datetime")
                .datetime.now(__import__("datetime").UTC)
                .isoformat()
                .replace("+00:00", "Z"),
            },
        )
        logger.info(f"Marked installation {installation['id']} as deleted")

        return {"action": "deleted", "installation_id": installation["id"]}

    def _handle_suspended(self, installation: dict) -> dict[str, Any]:
        """Handle GitHub App installation suspension.

        Suspension typically occurs due to billing issues or admin action.
        Updates the status to 'suspended' to prevent processing webhooks
        for this installation.

        Args:
            installation: Installation object from the webhook payload.

        Returns:
            Dict with action='suspended' and the installation_id.
        """
        table = self._get_table()

        table.update_item(
            Key={
                "pk": f"INSTALLATION#{installation['id']}",
                "sk": "CONFIG",
            },
            UpdateExpression="SET #status = :status",
            ExpressionAttributeNames={"#status": "status"},
            ExpressionAttributeValues={":status": "suspended"},
        )
        logger.info(f"Marked installation {installation['id']} as suspended")

        return {"action": "suspended", "installation_id": installation["id"]}

    def _handle_unsuspended(self, installation: dict) -> dict[str, Any]:
        """Handle GitHub App installation reactivation.

        Restores the installation to 'active' status after a suspension
        has been lifted.

        Args:
            installation: Installation object from the webhook payload.

        Returns:
            Dict with action='unsuspended' and the installation_id.
        """
        table = self._get_table()

        table.update_item(
            Key={
                "pk": f"INSTALLATION#{installation['id']}",
                "sk": "CONFIG",
            },
            UpdateExpression="SET #status = :status",
            ExpressionAttributeNames={"#status": "status"},
            ExpressionAttributeValues={":status": "active"},
        )
        logger.info(f"Marked installation {installation['id']} as active")

        return {"action": "unsuspended", "installation_id": installation["id"]}
