#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc.
# SPDX-License-Identifier: MIT

"""
Pydantic models for PR review data structures.

This module provides type-safe data models for parsing, validating, and
manipulating PR review data from GitHub. It ensures that no comments are
ever lost, with special attention to bot comments (especially Claude Code).

Usage:
    from models import ModelPRData, ModelPRComment, EnumPRCommentSource, BotType

    # Parse GitHub API response
    comment = ModelPRComment.from_github_response(github_data, EnumPRCommentSource.ISSUE_COMMENT)

    # Check if it's a Claude bot comment
    if comment.is_claude_bot():
        print(f"Claude comment found: {comment.body[:100]}")

    # Full PR analysis
    pr_data = ModelPRData.from_github_responses(...)
    analysis = ModelPRAnalysis.from_pr_data(pr_data)
"""

from __future__ import annotations

import re
from datetime import datetime
from enum import Enum
from functools import cached_property
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field, model_validator

# =============================================================================
# Bot Detection Patterns - CRITICAL: Must be bulletproof
# =============================================================================

CLAUDE_BOT_PATTERNS: list[str] = [
    "claude",
    "anthropic",
    "claude-code",
    "claude[bot]",
    "claude-bot",
    "claudebot",
    "claude-ai",
]

CODERABBIT_PATTERNS: list[str] = [
    "coderabbit",
    "coderabbitai",
    "coderabbit[bot]",
    "code-rabbit",
]

GITHUB_ACTIONS_PATTERNS: list[str] = [
    "github-actions",
    "github-actions[bot]",
]


def detect_bot_type(author: str) -> BotType:
    """
    Detect the type of bot from the author name.

    This is CRITICAL functionality - Claude bot comments must NEVER be
    misclassified as human comments.

    Args:
        author: The GitHub username or display name of the comment author.

    Returns:
        BotType enum indicating the type of author.

    Examples:
        >>> detect_bot_type("claude[bot]")
        BotType.CLAUDE_CODE
        >>> detect_bot_type("coderabbitai[bot]")
        BotType.CODERABBIT
        >>> detect_bot_type("octocat")
        BotType.HUMAN
    """
    if not author:
        return BotType.HUMAN

    author_lower = author.lower().strip()

    # Check Claude patterns first (highest priority)
    if any(pattern in author_lower for pattern in CLAUDE_BOT_PATTERNS):
        return BotType.CLAUDE_CODE

    # Check CodeRabbit patterns
    if any(pattern in author_lower for pattern in CODERABBIT_PATTERNS):
        return BotType.CODERABBIT

    # Check GitHub Actions patterns
    if any(pattern in author_lower for pattern in GITHUB_ACTIONS_PATTERNS):
        return BotType.GITHUB_ACTIONS

    # Generic bot detection
    if "[bot]" in author_lower or author_lower.endswith("-bot"):
        return BotType.OTHER_BOT

    return BotType.HUMAN


# =============================================================================
# Enums
# =============================================================================


class EnumPRCommentSource(str, Enum):
    """
    Source of a PR comment in GitHub.

    GitHub has multiple places where comments can appear:
    - REVIEW: Formal PR review comments
    - INLINE: Inline code comments on specific lines
    - PR_COMMENT: Comments in the PR conversation thread
    - ISSUE_COMMENT: Comments on the associated issue (where Claude bot posts!)
    """

    REVIEW = "review"
    INLINE = "inline"
    PR_COMMENT = "pr_comment"
    ISSUE_COMMENT = "issue_comment"

    def __str__(self) -> str:
        return self.value


class CommentSeverity(str, Enum):
    """
    Severity level of a PR comment/issue.

    Priority order (highest to lowest):
    - CRITICAL: Must fix before merge (security, data loss, crashes)
    - MAJOR: Should fix (bugs, performance issues, missing tests)
    - MINOR: Nice to fix (code quality, documentation)
    - NITPICK: Optional fixes (style, naming conventions)
    - UNCLASSIFIED: Severity not yet determined
    """

    CRITICAL = "critical"
    MAJOR = "major"
    MINOR = "minor"
    NITPICK = "nitpick"
    UNCLASSIFIED = "unclassified"

    def __str__(self) -> str:
        return self.value

    @property
    def is_blocking(self) -> bool:
        """Returns True if this severity level blocks merging."""
        return self in (CommentSeverity.CRITICAL, CommentSeverity.MAJOR)

    @property
    def priority_order(self) -> int:
        """Returns numeric priority (lower = higher priority)."""
        order = {
            CommentSeverity.CRITICAL: 0,
            CommentSeverity.MAJOR: 1,
            CommentSeverity.MINOR: 2,
            CommentSeverity.NITPICK: 3,
            CommentSeverity.UNCLASSIFIED: 4,
        }
        return order.get(self, 99)


class CommentStatus(str, Enum):
    """
    Resolution status of a PR comment.

    - UNADDRESSED: Not yet addressed by the PR author
    - POTENTIALLY_ADDRESSED: May have been addressed (needs verification)
    - RESOLVED: Confirmed resolved (GitHub thread marked resolved)
    - OUTDATED: Code has changed since the comment was made
    - WONT_FIX: Acknowledged but won't be fixed
    """

    UNADDRESSED = "unaddressed"
    POTENTIALLY_ADDRESSED = "potentially_addressed"
    RESOLVED = "resolved"
    OUTDATED = "outdated"
    WONT_FIX = "wont_fix"

    def __str__(self) -> str:
        return self.value

    @property
    def needs_attention(self) -> bool:
        """Returns True if this status needs attention."""
        return self in (CommentStatus.UNADDRESSED, CommentStatus.POTENTIALLY_ADDRESSED)

    @property
    def is_resolved(self) -> bool:
        """Returns True if this status indicates resolution."""
        return self in (
            CommentStatus.RESOLVED,
            CommentStatus.OUTDATED,
            CommentStatus.WONT_FIX,
        )


# Alias for backward compatibility and task specification
IssueStatus = CommentStatus
IssueSeverity = CommentSeverity


class BotType(str, Enum):
    """
    Type of bot (or human) that authored a comment.

    CLAUDE_CODE is given special treatment as these comments
    must NEVER be missed in analysis.
    """

    CLAUDE_CODE = "claude_code"
    CODERABBIT = "coderabbit"
    GITHUB_ACTIONS = "github_actions"
    OTHER_BOT = "other_bot"
    HUMAN = "human"

    def __str__(self) -> str:
        return self.value

    @property
    def is_bot(self) -> bool:
        """Returns True if this is any type of bot."""
        return self != BotType.HUMAN

    @property
    def is_ai_reviewer(self) -> bool:
        """Returns True if this is an AI code reviewer."""
        return self in (BotType.CLAUDE_CODE, BotType.CODERABBIT)


# =============================================================================
# Data Models
# =============================================================================


class ModelFileReference(BaseModel):
    """
    Reference to a specific location in a file.

    Used for inline comments that reference specific code locations.
    """

    model_config = ConfigDict(extra="allow", frozen=False)

    path: str = Field(..., description="File path relative to repository root")
    line: int | None = Field(None, ge=1, description="Line number (1-indexed)")
    end_line: int | None = Field(
        None, ge=1, description="End line for multi-line references"
    )
    diff_hunk: str | None = Field(None, description="Diff context around the line")

    @model_validator(mode="after")
    def validate_line_range(self) -> ModelFileReference:
        """Ensure end_line >= line if both are specified."""
        if self.line is not None and self.end_line is not None:
            if self.end_line < self.line:
                raise ValueError(
                    f"end_line ({self.end_line}) must be >= line ({self.line})"
                )
        return self

    def __repr__(self) -> str:
        if self.line:
            if self.end_line and self.end_line != self.line:
                return f"ModelFileReference({self.path}:{self.line}-{self.end_line})"
            return f"ModelFileReference({self.path}:{self.line})"
        return f"ModelFileReference({self.path})"

    @classmethod
    def from_github_response(cls, data: dict[str, Any]) -> ModelFileReference | None:
        """
        Create a ModelFileReference from GitHub API response data.

        Args:
            data: GitHub API response for a comment with file info.

        Returns:
            ModelFileReference if file info exists, None otherwise.
        """
        path = data.get("path")
        if not path:
            return None

        return cls(
            path=path,
            line=data.get("line") or data.get("original_line"),
            end_line=data.get("end_line") or data.get("original_end_line"),
            diff_hunk=data.get("diff_hunk"),
        )


class ModelStructuredSection(BaseModel):
    """
    A structured section within a bot's comment.

    Bot reviewers (like Claude and CodeRabbit) often structure their
    comments into sections like "Must Fix", "Should Fix", etc.
    """

    model_config = ConfigDict(extra="allow", frozen=False)

    section_type: Literal[
        "must_fix",
        "should_fix",
        "nice_to_have",
        "actionable_comments",
        "summary",
        "other",
    ] = Field(..., description="Type of section")
    content: str = Field(..., description="Raw content of the section")
    extracted_issues: list[str] = Field(
        default_factory=list, description="Individual issues extracted from content"
    )

    def __repr__(self) -> str:
        issue_count = len(self.extracted_issues)
        return f"ModelStructuredSection({self.section_type}, {issue_count} issues)"

    @classmethod
    def extract_from_body(cls, body: str) -> list[ModelStructuredSection]:
        """
        Extract structured sections from a comment body.

        Looks for common patterns like:
        - ## Must Fix / ## Critical
        - ## Should Fix / ## Major
        - ## Nice to Have / ## Minor
        - ## Actionable Comments
        - ## Summary

        Args:
            body: The raw comment body text.

        Returns:
            List of ModelStructuredSection objects found in the body.
        """
        if not body:
            return []

        sections: list[ModelStructuredSection] = []

        # Pattern to match markdown headers
        section_patterns = {
            "must_fix": r"(?:##?\s*(?:Must\s*Fix|Critical|Blocking))",
            "should_fix": r"(?:##?\s*(?:Should\s*Fix|Major|Important))",
            "nice_to_have": r"(?:##?\s*(?:Nice\s*to\s*Have|Minor|Optional|Nit))",
            "actionable_comments": r"(?:##?\s*(?:Actionable\s*Comments?|Action\s*Items?))",
            "summary": r"(?:##?\s*(?:Summary|Overview|TL;?DR))",
        }

        for section_type, pattern in section_patterns.items():
            matches = re.finditer(
                pattern + r"\s*\n(.*?)(?=\n##|\n---|\Z)",
                body,
                re.IGNORECASE | re.DOTALL,
            )
            for match in matches:
                content = match.group(1).strip()
                if content:
                    # Extract individual bullet points or numbered items
                    issues = re.findall(
                        r"^[\s]*[-*\d.]+\s*(.+)$", content, re.MULTILINE
                    )
                    sections.append(
                        cls(
                            section_type=section_type,  # type: ignore[arg-type]
                            content=content,
                            extracted_issues=issues,
                        )
                    )

        return sections


class ModelPRComment(BaseModel):
    """
    A single comment on a PR from any source.

    This is the core model for tracking PR feedback. It unifies comments
    from all GitHub endpoints (reviews, inline comments, PR comments,
    issue comments) into a single type-safe structure.

    CRITICAL: Claude bot comments are given special handling to ensure
    they are NEVER missed in analysis.
    """

    model_config = ConfigDict(extra="allow", frozen=False)

    id: str = Field(..., description="GitHub comment ID")
    source: EnumPRCommentSource = Field(..., description="Where this comment came from")
    author: str = Field(..., description="GitHub username of comment author")
    author_type: BotType = Field(default=BotType.HUMAN, description="Type of author")
    body: str = Field(..., description="Comment body text")
    severity: CommentSeverity = Field(
        default=CommentSeverity.UNCLASSIFIED, description="Severity level"
    )
    status: CommentStatus = Field(
        default=CommentStatus.UNADDRESSED, description="Resolution status"
    )
    file_ref: ModelFileReference | None = Field(
        None, description="File location if inline"
    )
    created_at: datetime = Field(..., description="When comment was created")
    updated_at: datetime | None = Field(
        None, description="When comment was last updated"
    )
    in_reply_to_id: str | None = Field(None, description="Parent comment ID if reply")
    structured_sections: list[ModelStructuredSection] = Field(
        default_factory=list, description="Parsed structured sections"
    )
    raw_json: dict[str, Any] = Field(
        default_factory=dict, description="Original GitHub API response"
    )
    # Resolution tracking fields
    thread_id: str | None = Field(
        None, description="GitHub review thread ID if part of a thread"
    )
    resolved_at: datetime | None = Field(
        None, description="When the thread was resolved"
    )
    resolved_by: str | None = Field(
        None, description="Username who resolved the thread"
    )
    is_outdated: bool = Field(
        default=False, description="True if the code has changed since comment"
    )

    @model_validator(mode="after")
    def detect_author_type_and_sections(self) -> ModelPRComment:
        """Auto-detect author type and parse structured sections."""
        # Always re-detect author type to ensure Claude comments aren't missed
        self.author_type = detect_bot_type(self.author)

        # Parse structured sections if not already done
        if not self.structured_sections and self.body:
            self.structured_sections = ModelStructuredSection.extract_from_body(
                self.body
            )

        # Auto-classify severity if unclassified
        if self.severity == CommentSeverity.UNCLASSIFIED:
            self.severity = self.extract_severity_from_content()

        return self

    def is_bot(self) -> bool:
        """Check if comment is from any bot."""
        return self.author_type.is_bot

    def is_claude_bot(self) -> bool:
        """
        Check if comment is from Claude Code bot.

        CRITICAL: This method must NEVER miss a Claude bot comment.
        Uses multiple fallback checks.
        """
        # Primary check via author_type
        if self.author_type == BotType.CLAUDE_CODE:
            return True

        # Fallback safety check - scan author name directly
        author_lower = self.author.lower()
        if any(pattern in author_lower for pattern in CLAUDE_BOT_PATTERNS):
            return True

        return False

    def is_actionable(self) -> bool:
        """
        Check if comment requires action.

        Returns True if:
        - Status needs attention (unaddressed or potentially addressed)
        - AND severity is not just a nitpick
        """
        return self.status.needs_attention and self.severity != CommentSeverity.NITPICK

    def is_blocking(self) -> bool:
        """Check if this comment blocks merge."""
        return self.severity.is_blocking and self.status == CommentStatus.UNADDRESSED

    def extract_severity_from_content(self) -> CommentSeverity:
        """
        Extract severity level from comment content.

        Looks for common patterns in the comment body:
        - Critical/blocking/security -> CRITICAL
        - Major/important/bug -> MAJOR
        - Minor/suggestion -> MINOR
        - Nit/nitpick/optional -> NITPICK

        Returns:
            Detected severity or UNCLASSIFIED if none found.
        """
        if not self.body:
            return CommentSeverity.UNCLASSIFIED

        body_lower = self.body.lower()

        # Critical patterns
        critical_patterns = [
            r"\bcritical\b",
            r"\bblocking\b",
            r"\bsecurity\b",
            r"\bvulnerab",
            r"\bdata\s*loss\b",
            r"\bcrash",
            r"\bmust\s*fix\b",
            r"\bbreak(?:ing|s)?\b",
            r"\btest\s*failure\b",
            r"\bfails?\b",
            r"\bbroken\b",
            r"\bexploit\b",
            r"\binjection\b",
        ]
        if any(re.search(p, body_lower) for p in critical_patterns):
            return CommentSeverity.CRITICAL

        # Major patterns
        major_patterns = [
            r"\bmajor\b",
            r"\bimportant\b",
            r"\bbug\b",
            r"\berror\b",
            r"\bshould\s*fix\b",
            r"\bperformance\b",
            r"\bmissing\s*test",
            r"\bincorrect\b",
            r"\bwrong\b",
            r"\barchitecture\b",
            r"\binconsistent\b",
        ]
        if any(re.search(p, body_lower) for p in major_patterns):
            return CommentSeverity.MAJOR

        # Minor patterns
        minor_patterns = [
            r"\bminor\b",
            r"\bsuggestion\b",
            r"\bconsider\b",
            r"\bcould\b",
            r"\bmight\b",
            r"\bnice\s*to\s*have\b",
        ]
        if any(re.search(p, body_lower) for p in minor_patterns):
            return CommentSeverity.MINOR

        # Nitpick patterns
        nitpick_patterns = [
            r"\bnit(?:pick)?\b",
            r"\boptional\b",
            r"\bstyle\b",
            r"\bformatting\b",
            r"\bnaming\b",
            r"\bcosmetic\b",
        ]
        if any(re.search(p, body_lower) for p in nitpick_patterns):
            return CommentSeverity.NITPICK

        return CommentSeverity.UNCLASSIFIED

    @classmethod
    def classify_severity(cls, body: str) -> CommentSeverity:
        """
        Classify comment severity based on body content.

        Class method for external use without creating an instance.

        Args:
            body: The comment body text.

        Returns:
            Detected severity level.
        """
        # Create a temporary instance to use the instance method
        temp = cls(
            id="temp",
            source=EnumPRCommentSource.PR_COMMENT,
            author="temp",
            body=body,
            created_at=datetime.now(),
            severity=CommentSeverity.UNCLASSIFIED,
        )
        return temp.severity

    def __repr__(self) -> str:
        source_str = self.source.value
        author_str = f"{self.author} ({self.author_type.value})"
        severity_str = self.severity.value
        body_preview = self.body[:50] + "..." if len(self.body) > 50 else self.body
        body_preview = body_preview.replace("\n", " ")
        return (
            f"ModelPRComment(id={self.id}, source={source_str}, "
            f"author={author_str}, severity={severity_str}, "
            f"body={body_preview!r})"
        )

    @classmethod
    def from_github_response(
        cls,
        data: dict[str, Any],
        source: EnumPRCommentSource,
        *,
        default_severity: CommentSeverity = CommentSeverity.UNCLASSIFIED,
        default_status: CommentStatus = CommentStatus.UNADDRESSED,
    ) -> ModelPRComment:
        """
        Create a ModelPRComment from GitHub API response data.

        Args:
            data: Raw GitHub API response for a single comment.
            source: The endpoint this comment came from.
            default_severity: Default severity if not detected.
            default_status: Default status for new comments.

        Returns:
            ModelPRComment instance with all fields populated.

        Example:
            >>> data = {"id": 123, "user": {"login": "claude[bot]"}, ...}
            >>> comment = ModelPRComment.from_github_response(data, EnumPRCommentSource.ISSUE_COMMENT)
            >>> comment.is_claude_bot()
            True
        """
        # Extract author from various GitHub response formats
        author = ""
        if data.get("user"):
            author = data["user"].get("login", "")
        elif data.get("author"):
            if isinstance(data["author"], dict):
                author = data["author"].get("login", "")
            else:
                author = str(data["author"])

        # Extract timestamps
        created_at_str = data.get("created_at") or data.get("submitted_at")
        created_at = (
            datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
            if created_at_str
            else datetime.now()
        )

        updated_at = None
        if data.get("updated_at"):
            updated_at = datetime.fromisoformat(
                data["updated_at"].replace("Z", "+00:00")
            )

        # Extract body
        body = data.get("body") or ""

        # Extract file reference for inline comments
        file_ref = ModelFileReference.from_github_response(data)

        # Extract reply-to ID
        in_reply_to_id = None
        if data.get("in_reply_to_id"):
            in_reply_to_id = str(data["in_reply_to_id"])

        return cls(
            id=str(data.get("id", "")),
            source=source,
            author=author,
            body=body,
            severity=default_severity,
            status=default_status,
            file_ref=file_ref,
            created_at=created_at,
            updated_at=updated_at,
            in_reply_to_id=in_reply_to_id,
            raw_json=data,
        )


class ModelPRReview(BaseModel):
    """
    A formal PR review (approve, request changes, comment).

    Represents a single review submission, which may contain
    multiple inline comments.
    """

    model_config = ConfigDict(extra="allow", frozen=False)

    id: str = Field(..., description="GitHub review ID")
    author: str = Field(..., description="GitHub username of reviewer")
    author_type: BotType = Field(default=BotType.HUMAN, description="Type of author")
    state: Literal[
        "APPROVED", "CHANGES_REQUESTED", "COMMENTED", "PENDING", "DISMISSED"
    ] = Field(..., description="Review state")
    body: str | None = Field(None, description="Review body text")
    submitted_at: datetime = Field(..., description="When review was submitted")
    comments: list[ModelPRComment] = Field(
        default_factory=list, description="Inline comments in this review"
    )
    structured_sections: list[ModelStructuredSection] = Field(
        default_factory=list, description="Parsed structured sections from body"
    )
    raw_json: dict[str, Any] = Field(
        default_factory=dict, description="Original GitHub API response"
    )

    @model_validator(mode="after")
    def detect_author_type_and_sections(self) -> ModelPRReview:
        """Auto-detect author type and parse structured sections."""
        self.author_type = detect_bot_type(self.author)

        if not self.structured_sections and self.body:
            self.structured_sections = ModelStructuredSection.extract_from_body(
                self.body
            )

        return self

    def is_bot(self) -> bool:
        """Check if review is from any bot."""
        return self.author_type.is_bot

    def is_claude_bot(self) -> bool:
        """Check if review is from Claude Code bot."""
        return self.author_type == BotType.CLAUDE_CODE

    def is_blocking(self) -> bool:
        """Check if review blocks merging."""
        return self.state == "CHANGES_REQUESTED"

    def __repr__(self) -> str:
        return (
            f"ModelPRReview(id={self.id}, author={self.author} ({self.author_type.value}), "
            f"state={self.state}, comments={len(self.comments)})"
        )

    @classmethod
    def from_github_response(cls, data: dict[str, Any]) -> ModelPRReview:
        """
        Create a ModelPRReview from GitHub API response data.

        Args:
            data: Raw GitHub API response for a review.

        Returns:
            ModelPRReview instance.
        """
        author = ""
        if data.get("user"):
            author = data["user"].get("login", "")

        submitted_at_str = data.get("submitted_at")
        submitted_at = (
            datetime.fromisoformat(submitted_at_str.replace("Z", "+00:00"))
            if submitted_at_str
            else datetime.now()
        )

        return cls(
            id=str(data.get("id", "")),
            author=author,
            state=data.get("state", "COMMENTED"),
            body=data.get("body"),
            submitted_at=submitted_at,
            raw_json=data,
        )


class ModelPRData(BaseModel):
    """
    Complete PR data aggregated from all GitHub endpoints.

    This is the main container for all PR information, unifying
    data from reviews, inline comments, PR comments, and issue comments.
    """

    model_config = ConfigDict(extra="allow", frozen=False)

    pr_number: int = Field(..., ge=1, description="PR number")
    repository: str = Field(..., description="Repository in owner/repo format")
    title: str = Field(default="", description="PR title")
    author: str = Field(default="", description="PR author username")
    base_branch: str = Field(default="main", description="Target branch")
    head_branch: str = Field(default="", description="Source branch")
    state: Literal["open", "closed", "merged"] = Field(
        default="open", description="PR state"
    )
    created_at: datetime | None = Field(None, description="When PR was created")
    updated_at: datetime | None = Field(None, description="When PR was last updated")
    reviews: list[ModelPRReview] = Field(
        default_factory=list, description="Formal reviews"
    )
    comments: list[ModelPRComment] = Field(
        default_factory=list, description="All unified comments"
    )
    fetched_at: datetime = Field(
        default_factory=datetime.now, description="When data was fetched"
    )
    fetch_source: Literal["github_api", "cache", "gh_cli"] = Field(
        default="github_api", description="How data was fetched"
    )
    raw_json: dict[str, Any] = Field(
        default_factory=dict, description="Original PR data from GitHub"
    )

    def __repr__(self) -> str:
        return (
            f"ModelPRData(#{self.pr_number} {self.repository}, "
            f"reviews={len(self.reviews)}, comments={len(self.comments)})"
        )

    def get_all_comments(self) -> list[ModelPRComment]:
        """
        Get all comments from all sources.

        Returns comments from:
        - PR-level comments
        - Review inline comments
        - Issue comments (where Claude posts!)

        Returns:
            List of all ModelPRComment objects, sorted by created_at.
        """
        all_comments: list[ModelPRComment] = list(self.comments)

        # Add comments from reviews
        for review in self.reviews:
            all_comments.extend(review.comments)

        # Sort by creation time
        return sorted(all_comments, key=lambda c: c.created_at)

    def get_bot_comments(self, bot_type: BotType | None = None) -> list[ModelPRComment]:
        """
        Get all comments from bots.

        Args:
            bot_type: Specific bot type to filter, or None for all bots.

        Returns:
            List of bot comments, sorted by created_at.
        """
        all_comments = self.get_all_comments()

        if bot_type:
            return [c for c in all_comments if c.author_type == bot_type]

        return [c for c in all_comments if c.is_bot()]

    def get_claude_comments(self) -> list[ModelPRComment]:
        """
        Get all Claude Code bot comments.

        CRITICAL: This method ensures Claude comments are NEVER missed.

        Returns:
            List of Claude bot comments.
        """
        return self.get_bot_comments(BotType.CLAUDE_CODE)

    def get_unaddressed_comments(self) -> list[ModelPRComment]:
        """
        Get all comments that need attention.

        Returns:
            Comments with UNADDRESSED or POTENTIALLY_ADDRESSED status.
        """
        all_comments = self.get_all_comments()
        return [c for c in all_comments if c.status.needs_attention]

    def get_by_severity(self, severity: CommentSeverity) -> list[ModelPRComment]:
        """
        Get all comments with a specific severity.

        Args:
            severity: The severity level to filter by.

        Returns:
            Comments matching the severity level.
        """
        all_comments = self.get_all_comments()
        return [c for c in all_comments if c.severity == severity]

    def get_actionable_issues(self) -> list[ModelPRComment]:
        """
        Get all actionable issues that need resolution.

        Filters comments that:
        - Need attention (unaddressed or potentially addressed)
        - Are not just nitpicks

        Returns:
            List of actionable comments, sorted by severity.
        """
        all_comments = self.get_all_comments()
        actionable = [c for c in all_comments if c.is_actionable()]

        # Sort by severity (critical first)
        return sorted(actionable, key=lambda c: c.severity.priority_order)

    def get_comments_by_source(
        self, source: EnumPRCommentSource
    ) -> list[ModelPRComment]:
        """
        Get comments from a specific source.

        Args:
            source: The source endpoint to filter by.

        Returns:
            Comments from that source.
        """
        return [c for c in self.comments if c.source == source]

    @property
    def total_comments(self) -> int:
        """Total number of comments across all sources."""
        return len(self.get_all_comments())

    @property
    def total_reviews(self) -> int:
        """Total number of formal reviews."""
        return len(self.reviews)

    @classmethod
    def from_github_responses(
        cls,
        pr_data: dict[str, Any],
        reviews: list[dict[str, Any]] | None = None,
        inline_comments: list[dict[str, Any]] | None = None,
        pr_comments: list[dict[str, Any]] | None = None,
        issue_comments: list[dict[str, Any]] | None = None,
        *,
        fetch_source: Literal["github_api", "cache", "gh_cli"] = "github_api",
    ) -> ModelPRData:
        """
        Create ModelPRData from GitHub API responses.

        Args:
            pr_data: PR details from GitHub API.
            reviews: List of reviews from /pulls/{pr}/reviews.
            inline_comments: List of inline comments from /pulls/{pr}/comments.
            pr_comments: List of PR comments (if separate endpoint).
            issue_comments: List of issue comments (where Claude posts!).
            fetch_source: How the data was fetched.

        Returns:
            ModelPRData instance with all data unified.
        """
        reviews = reviews or []
        inline_comments = inline_comments or []
        pr_comments = pr_comments or []
        issue_comments = issue_comments or []

        # Parse PR metadata
        created_at = None
        if pr_data.get("created_at"):
            created_at = datetime.fromisoformat(
                pr_data["created_at"].replace("Z", "+00:00")
            )

        updated_at = None
        if pr_data.get("updated_at"):
            updated_at = datetime.fromisoformat(
                pr_data["updated_at"].replace("Z", "+00:00")
            )

        # Determine state
        state: Literal["open", "closed", "merged"] = "open"
        if pr_data.get("merged"):
            state = "merged"
        elif pr_data.get("state") == "closed":
            state = "closed"

        # Parse reviews
        parsed_reviews: list[ModelPRReview] = []
        for review_data in reviews:
            review = ModelPRReview.from_github_response(review_data)

            # Find inline comments that belong to this review
            review_id = str(review_data.get("id", ""))
            review_comments = [
                ModelPRComment.from_github_response(c, EnumPRCommentSource.INLINE)
                for c in inline_comments
                if str(c.get("pull_request_review_id", "")) == review_id
            ]
            review.comments = review_comments
            parsed_reviews.append(review)

        # Parse standalone inline comments (not part of a review)
        review_ids = {str(r.id) for r in parsed_reviews}
        standalone_inline = [
            ModelPRComment.from_github_response(c, EnumPRCommentSource.INLINE)
            for c in inline_comments
            if str(c.get("pull_request_review_id", "")) not in review_ids
        ]

        # Parse PR comments
        parsed_pr_comments = [
            ModelPRComment.from_github_response(c, EnumPRCommentSource.PR_COMMENT)
            for c in pr_comments
        ]

        # Parse issue comments (CRITICAL: This is where Claude posts!)
        parsed_issue_comments = [
            ModelPRComment.from_github_response(c, EnumPRCommentSource.ISSUE_COMMENT)
            for c in issue_comments
        ]

        # Combine all comments
        all_comments = standalone_inline + parsed_pr_comments + parsed_issue_comments

        # Extract repository info
        repository = ""
        if pr_data.get("base", {}).get("repo", {}).get("full_name"):
            repository = pr_data["base"]["repo"]["full_name"]
        elif pr_data.get("repository"):
            repository = pr_data["repository"]

        # Extract author
        author = ""
        if pr_data.get("user", {}).get("login"):
            author = pr_data["user"]["login"]
        elif pr_data.get("author", {}).get("login"):
            author = pr_data["author"]["login"]

        return cls(
            pr_number=pr_data.get("number", 0),
            repository=repository,
            title=pr_data.get("title", ""),
            author=author,
            base_branch=pr_data.get("base", {}).get("ref", "main"),
            head_branch=pr_data.get("head", {}).get("ref", ""),
            state=state,
            created_at=created_at,
            updated_at=updated_at,
            reviews=parsed_reviews,
            comments=all_comments,
            fetch_source=fetch_source,
            raw_json=pr_data,
        )


class ModelPRAnalysis(BaseModel):
    """
    Analysis results for a PR.

    Provides aggregated statistics and categorized issues
    for easy consumption by review workflows.
    """

    model_config = ConfigDict(extra="allow", frozen=False)

    pr_data: ModelPRData = Field(..., description="Source PR data")
    analyzed_at: datetime = Field(
        default_factory=datetime.now, description="When analysis was performed"
    )
    total_comments: int = Field(default=0, description="Total comment count")
    by_severity: dict[str, int] = Field(
        default_factory=dict, description="Comment counts by severity"
    )
    by_status: dict[str, int] = Field(
        default_factory=dict, description="Comment counts by status"
    )
    by_author_type: dict[str, int] = Field(
        default_factory=dict, description="Comment counts by author type"
    )
    critical_issues: list[ModelPRComment] = Field(
        default_factory=list, description="Critical severity issues"
    )
    major_issues: list[ModelPRComment] = Field(
        default_factory=list, description="Major severity issues"
    )
    minor_issues: list[ModelPRComment] = Field(
        default_factory=list, description="Minor severity issues"
    )
    nitpick_issues: list[ModelPRComment] = Field(
        default_factory=list, description="Nitpick severity issues"
    )
    claude_issues: list[ModelPRComment] = Field(
        default_factory=list, description="All Claude bot comments (NEVER MISS)"
    )
    merge_blockers: list[ModelPRComment] = Field(
        default_factory=list, description="Issues that block merging"
    )
    summary: str = Field(default="", description="Human-readable summary")

    def __repr__(self) -> str:
        return (
            f"ModelPRAnalysis(PR #{self.pr_data.pr_number}, "
            f"total={self.total_comments}, critical={len(self.critical_issues)}, "
            f"major={len(self.major_issues)}, claude={len(self.claude_issues)})"
        )

    def can_merge(self) -> bool:
        """
        Check if PR can be merged based on analysis.

        Returns True if no blocking issues remain.
        """
        return len(self.merge_blockers) == 0

    def is_merge_ready(self) -> bool:
        """Alias for can_merge() for backward compatibility."""
        return self.can_merge()

    def get_blocking_count(self) -> int:
        """Get count of merge-blocking issues."""
        return len(self.merge_blockers)

    def get_merge_status(self) -> str:
        """Get human-readable merge status."""
        if self.can_merge():
            return "Ready to merge"

        blockers = len(self.merge_blockers)
        critical = len(self.critical_issues)
        major = len(self.major_issues)

        parts = []
        if critical:
            parts.append(f"{critical} critical")
        if major:
            parts.append(f"{major} major")

        return f"Blocked: {', '.join(parts)} issue(s) remaining"

    @classmethod
    def from_pr_data(cls, pr_data: ModelPRData) -> ModelPRAnalysis:
        """
        Create analysis from PR data.

        Args:
            pr_data: ModelPRData instance to analyze.

        Returns:
            ModelPRAnalysis with all statistics computed.
        """
        all_comments = pr_data.get_all_comments()

        # Count by severity
        by_severity: dict[str, int] = {}
        for severity in CommentSeverity:
            count = len([c for c in all_comments if c.severity == severity])
            by_severity[severity.value] = count

        # Count by status
        by_status: dict[str, int] = {}
        for status in CommentStatus:
            count = len([c for c in all_comments if c.status == status])
            by_status[status.value] = count

        # Count by author type
        by_author_type: dict[str, int] = {}
        for author_type in BotType:
            count = len([c for c in all_comments if c.author_type == author_type])
            by_author_type[author_type.value] = count

        # Categorize issues
        critical_issues = pr_data.get_by_severity(CommentSeverity.CRITICAL)
        major_issues = pr_data.get_by_severity(CommentSeverity.MAJOR)
        minor_issues = pr_data.get_by_severity(CommentSeverity.MINOR)
        nitpick_issues = pr_data.get_by_severity(CommentSeverity.NITPICK)
        claude_issues = pr_data.get_claude_comments()

        # Determine merge blockers (critical + major that need attention)
        merge_blockers = [
            c
            for c in all_comments
            if c.severity.is_blocking and c.status.needs_attention
        ]

        # Generate summary
        summary_parts = [
            f"PR #{pr_data.pr_number}: {pr_data.title}",
            f"Total comments: {len(all_comments)}",
        ]

        if critical_issues:
            summary_parts.append(f"Critical issues: {len(critical_issues)}")
        if major_issues:
            summary_parts.append(f"Major issues: {len(major_issues)}")
        if claude_issues:
            summary_parts.append(f"Claude Code comments: {len(claude_issues)}")

        if merge_blockers:
            summary_parts.append(
                f"BLOCKED: {len(merge_blockers)} issues must be resolved"
            )
        else:
            summary_parts.append("Ready to merge")

        summary = "\n".join(summary_parts)

        return cls(
            pr_data=pr_data,
            total_comments=len(all_comments),
            by_severity=by_severity,
            by_status=by_status,
            by_author_type=by_author_type,
            critical_issues=critical_issues,
            major_issues=major_issues,
            minor_issues=minor_issues,
            nitpick_issues=nitpick_issues,
            claude_issues=claude_issues,
            merge_blockers=merge_blockers,
            summary=summary,
        )

    def to_json(self) -> str:
        """Serialize analysis to JSON string."""
        return self.model_dump_json(indent=2)

    def to_dict(self) -> dict[str, Any]:
        """Convert analysis to dictionary."""
        return self.model_dump()


class ModelPRIssue(BaseModel):
    """
    A collated PR review issue with resolution tracking.

    This model is used by the collate-issues script to represent
    a single actionable issue extracted from PR review comments.
    It includes resolution status detection based on GitHub thread
    status and file modification tracking.

    Usage:
        from models import ModelPRIssue, IssueSeverity, IssueStatus

        issue = ModelPRIssue(
            file_path="src/main.py",
            line_number=42,
            severity=IssueSeverity.CRITICAL,
            description="Missing null check",
            status=IssueStatus.UNADDRESSED,
        )

        if issue.is_resolved:
            print(f"Resolved at {issue.resolved_at}")
    """

    model_config = ConfigDict(extra="allow", frozen=False)

    file_path: str = Field(
        default="", description="File path relative to repository root"
    )
    line_number: int | None = Field(
        None, ge=1, description="Line number (1-indexed) if applicable"
    )
    severity: CommentSeverity = Field(
        ..., description="Issue severity level (critical/major/minor/nitpick)"
    )
    description: str = Field(..., description="Issue description/summary")
    status: CommentStatus = Field(
        default=CommentStatus.UNADDRESSED, description="Resolution status"
    )
    comment_id: int | None = Field(None, description="GitHub comment ID for tracking")
    thread_id: str | None = Field(
        None, description="GitHub review thread ID if part of a thread"
    )
    resolved_at: datetime | None = Field(
        None, description="When the issue was resolved"
    )
    resolved_by: str | None = Field(None, description="Username who resolved the issue")
    is_outdated: bool = Field(
        default=False, description="True if file changed after comment"
    )
    source_comment: ModelPRComment | None = Field(
        None, description="Original ModelPRComment this issue was extracted from"
    )

    @property
    def is_resolved(self) -> bool:
        """Check if this issue is resolved (any resolution status)."""
        return self.status.is_resolved

    @property
    def is_open(self) -> bool:
        """Check if this issue is still open."""
        return not self.is_resolved

    @property
    def location(self) -> str:
        """Get formatted location string like '[path:line]'."""
        if not self.file_path:
            return ""
        if self.line_number:
            return f"[{self.file_path}:{self.line_number}]"
        return f"[{self.file_path}]"

    @property
    def severity_emoji(self) -> str:
        """Get emoji indicator for severity level."""
        emoji_map = {
            CommentSeverity.CRITICAL: "🔴",
            CommentSeverity.MAJOR: "🟠",
            CommentSeverity.MINOR: "🟡",
            CommentSeverity.NITPICK: "⚪",
            CommentSeverity.UNCLASSIFIED: "❓",
        }
        return emoji_map.get(self.severity, "❓")

    @property
    def status_indicator(self) -> str:
        """Get status indicator prefix for display."""
        if self.status == CommentStatus.RESOLVED:
            return "[RESOLVED]"
        if self.status == CommentStatus.OUTDATED or self.is_outdated:
            return "[OUTDATED]"
        if self.status == CommentStatus.WONT_FIX:
            return "[WONT_FIX]"
        return ""

    def format_display(self, show_status: bool = True) -> str:
        """Format issue for display output.

        Args:
            show_status: Whether to include resolution status indicator.

        Returns:
            Formatted string like "🔴 [RESOLVED] [path:42] Issue description"
        """
        parts = [self.severity_emoji]
        if show_status and self.status_indicator:
            parts.append(self.status_indicator)
        if self.location:
            parts.append(self.location)
        parts.append(self.description)
        return " ".join(parts)

    @classmethod
    def from_pr_comment(
        cls,
        comment: ModelPRComment,
        description: str | None = None,
        severity: CommentSeverity | None = None,
    ) -> ModelPRIssue:
        """Create a ModelPRIssue from a ModelPRComment.

        Args:
            comment: The source ModelPRComment.
            description: Override description (defaults to first line of body).
            severity: Override severity (defaults to comment's severity).

        Returns:
            ModelPRIssue instance linked to the source comment.
        """
        # Extract file path and line from file_ref
        file_path = ""
        line_number = None
        if comment.file_ref:
            file_path = comment.file_ref.path
            line_number = comment.file_ref.line

        # Use first meaningful line of body as description if not provided
        if description is None:
            lines = comment.body.strip().split("\n")
            for line in lines:
                line = line.strip()
                # Skip headers, empty lines, and common prefixes
                if line and not line.startswith("#") and not line.startswith("<!--"):
                    description = line[:200]  # Truncate if very long
                    break
            if not description:
                description = comment.body[:200]

        # Determine status based on resolution fields
        status = comment.status
        if comment.is_outdated:
            status = CommentStatus.OUTDATED
        elif comment.resolved_at or comment.resolved_by:
            status = CommentStatus.RESOLVED

        return cls(
            file_path=file_path,
            line_number=line_number,
            severity=severity or comment.severity,
            description=description,
            status=status,
            # Safe conversion: only convert purely numeric IDs (REST API)
            # GraphQL node IDs like "IC_kwDOABC123" cannot be stored as int
            comment_id=int(comment.id) if comment.id and comment.id.isdigit() else None,
            thread_id=comment.thread_id,
            resolved_at=comment.resolved_at,
            resolved_by=comment.resolved_by,
            is_outdated=comment.is_outdated,
            source_comment=comment,
        )

    def __repr__(self) -> str:
        status_str = f" [{self.status.value}]" if self.is_resolved else ""
        loc = self.location or "(no location)"
        return f"ModelPRIssue({self.severity.value}{status_str}, {loc}, {self.description[:40]}...)"


class ModelCollatedIssues(BaseModel):
    """
    Container for collated PR issues organized by severity.

    This model represents the output of the collate-issues script,
    providing easy access to issues grouped by severity and status.

    Note:
        For optimal performance, treat instances as immutable after creation.
        The ``all_issues`` property is cached and won't reflect post-creation
        modifications to the issue lists.
    """

    model_config = ConfigDict(extra="allow", frozen=False)

    pr_number: int = Field(..., ge=1, description="PR number")
    repository: str = Field(default="", description="Repository in owner/repo format")
    collated_at: datetime = Field(
        default_factory=datetime.now, description="When issues were collated"
    )
    critical: list[ModelPRIssue] = Field(
        default_factory=list, description="Critical severity issues"
    )
    major: list[ModelPRIssue] = Field(
        default_factory=list, description="Major severity issues"
    )
    minor: list[ModelPRIssue] = Field(
        default_factory=list, description="Minor severity issues"
    )
    nitpick: list[ModelPRIssue] = Field(
        default_factory=list, description="Nitpick severity issues"
    )
    unclassified: list[ModelPRIssue] = Field(
        default_factory=list, description="Unclassified issues"
    )

    @cached_property
    def all_issues(self) -> list[ModelPRIssue]:
        """Get all issues across all severity levels.

        Returns a cached concatenation of all issue lists. The cache is
        invalidated if the model is modified (note: Pydantic models should
        be treated as immutable after creation for caching to work correctly).
        """
        return (
            self.critical + self.major + self.minor + self.nitpick + self.unclassified
        )

    @property
    def open_issues(self) -> list[ModelPRIssue]:
        """Get all issues that are not resolved."""
        return [i for i in self.all_issues if i.is_open]

    @property
    def resolved_issues(self) -> list[ModelPRIssue]:
        """Get all resolved issues."""
        return [i for i in self.all_issues if i.is_resolved]

    @property
    def total_count(self) -> int:
        """Total number of issues."""
        return len(self.all_issues)

    @property
    def open_count(self) -> int:
        """Number of open issues."""
        return len(self.open_issues)

    @property
    def resolved_count(self) -> int:
        """Number of resolved issues."""
        return len(self.resolved_issues)

    @property
    def blocking_count(self) -> int:
        """Number of open critical + major issues (blocking merge)."""
        return sum(1 for i in self.critical + self.major if i.is_open)

    def filter_by_status(
        self, hide_resolved: bool = False, show_resolved_only: bool = False
    ) -> ModelCollatedIssues:
        """Return a new ModelCollatedIssues with filtered issues.

        Args:
            hide_resolved: If True, exclude resolved issues.
            show_resolved_only: If True, only include resolved issues.

        Returns:
            New ModelCollatedIssues instance with filtered issues.

        Raises:
            ValueError: If both hide_resolved and show_resolved_only are True.

        Example:
            >>> issues = ModelCollatedIssues(pr_number=40, critical=[...])
            >>> open_only = issues.filter_by_status(hide_resolved=True)
            >>> resolved_only = issues.filter_by_status(show_resolved_only=True)
        """
        if hide_resolved and show_resolved_only:
            raise ValueError("Cannot use both hide_resolved and show_resolved_only")

        def filter_list(issues: list[ModelPRIssue]) -> list[ModelPRIssue]:
            if hide_resolved:
                return [i for i in issues if i.is_open]
            if show_resolved_only:
                return [i for i in issues if i.is_resolved]
            return issues

        return ModelCollatedIssues(
            pr_number=self.pr_number,
            repository=self.repository,
            collated_at=self.collated_at,
            critical=filter_list(self.critical),
            major=filter_list(self.major),
            minor=filter_list(self.minor),
            nitpick=filter_list(self.nitpick),
            unclassified=filter_list(self.unclassified),
        )

    def get_summary(self, include_nitpicks: bool = False) -> str:
        """Generate a human-readable summary string.

        Args:
            include_nitpicks: Whether to include nitpicks in count.

        Returns:
            Summary string like "3 critical, 5 major, 2 minor = 10 actionable"
        """
        parts = []
        if self.critical:
            parts.append(f"{len(self.critical)} critical")
        if self.major:
            parts.append(f"{len(self.major)} major")
        if self.minor:
            parts.append(f"{len(self.minor)} minor")
        if include_nitpicks and self.nitpick:
            parts.append(f"{len(self.nitpick)} nitpick")

        actionable = len(self.critical) + len(self.major) + len(self.minor)
        if include_nitpicks:
            actionable += len(self.nitpick)

        if not parts:
            return "No issues found"

        return f"Summary: {', '.join(parts)} = {actionable} actionable issues"


# =============================================================================
# Utility Functions
# =============================================================================


def parse_github_datetime(dt_str: str | None) -> datetime | None:
    """
    Parse a GitHub API datetime string.

    Args:
        dt_str: ISO format datetime string from GitHub API.

    Returns:
        datetime object or None if parsing fails.
    """
    if not dt_str:
        return None
    try:
        return datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
    except (ValueError, AttributeError):
        return None


def merge_comments_by_id(comments: list[ModelPRComment]) -> list[ModelPRComment]:
    """
    Merge duplicate comments by ID.

    If the same comment appears from multiple sources, keep the most
    complete version.

    Args:
        comments: List of comments that may contain duplicates.

    Returns:
        Deduplicated list of comments.
    """
    seen: dict[str, ModelPRComment] = {}

    for comment in comments:
        if comment.id in seen:
            # Keep the one with more information
            existing = seen[comment.id]
            if len(comment.body) > len(existing.body):
                seen[comment.id] = comment
        else:
            seen[comment.id] = comment

    return list(seen.values())


def classify_severity(body: str) -> CommentSeverity:
    """
    Classify comment severity based on body content.

    Standalone function for use without creating a ModelPRComment instance.

    Args:
        body: The comment body text.

    Returns:
        Detected severity level.
    """
    return ModelPRComment.classify_severity(body)


# =============================================================================
# Module-level exports
# =============================================================================

__all__ = [
    # Constants
    "CLAUDE_BOT_PATTERNS",
    "CODERABBIT_PATTERNS",
    "GITHUB_ACTIONS_PATTERNS",
    "BotType",
    "ModelCollatedIssues",
    "CommentSeverity",
    "CommentStatus",
    # Models
    "ModelFileReference",
    # Enum aliases (for backward compatibility)
    "IssueSeverity",
    "IssueStatus",
    "ModelPRAnalysis",
    "ModelPRComment",
    # Enums
    "EnumPRCommentSource",
    "ModelPRData",
    "ModelPRIssue",
    "ModelPRReview",
    "ModelStructuredSection",
    "classify_severity",
    # Functions
    "detect_bot_type",
    "merge_comments_by_id",
    "parse_github_datetime",
]
