"""
Core data models for CVE Extractor.

Provides CVERecord and GitHubPatch class hierarchies for representing
CVE data and GitHub patch information.
"""

import json
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path

from cwe_tree import query as cwe_query  # type: ignore[import-untyped]  

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class CVERecord:
    """
    Immutable representation of a single CVE from JSON with parsed fields.

    Attributes:
        cve_id: CVE identifier (e.g., "CVE-2024-1234")
        cwe_types: CWE classifications (e.g., ["CWE-79", "CWE-89"])
        description: Full vulnerability description
        affected_products: Affected software products
        references: Reference URLs (may contain GitHub patches)
        published_date: Publication date (ISO format)
    """

    cve_id: str
    cwe_types: list[str]
    description: str
    affected_products: list[str]
    references: list[str]
    published_date: str

    @staticmethod
    def load_from_file(file_path: Path) -> "CVERecord":
        """
        Load and parse CVE from JSON file.

        Args:
            file_path: Path to CVE JSON file

        Returns:
            CVERecord instance

        Raises:
            OSError: File read error
            json.JSONDecodeError: JSON parsing error
            KeyError: Required field missing in CVE data
            ValueError: CVE data malformed or invalid or non-CVE metadata file
        """
        # Skip known non-CVE metadata files
        file_name = file_path.name.lower()
        non_cve_files = {
            "deltalog.json",
            "index.json",
            "readme.json",
            "metadata.json",
            "settings.json",
        }
        if file_name in non_cve_files:
            raise ValueError(f"Skipping metadata file: {file_path.name}")

        with open(file_path, encoding="utf-8") as f:
            cve_data = json.load(f)

        return CVERecord._from_dict(cve_data, file_path.stem)

    @staticmethod
    def _from_dict(cve_data: dict, cve_id: str) -> "CVERecord":
        """Parse CVE from dictionary."""
        # Validate input is a dictionary (not a list or other type)
        if not isinstance(cve_data, dict):
            raise ValueError(f"Invalid CVE data structure: {type(cve_data).__name__} object has no attribute 'get'")
        
        try:
            containers = cve_data.get("containers", {})
            cna = containers.get("cna", {})

            # Extract CWE types
            cwe_types = []
            problem_types = cna.get("problemTypes", [])
            for pt in problem_types:
                for desc in pt.get("descriptions", []):
                    cwe_id = desc.get("cweId") or desc.get("type", "")
                    if cwe_id and cwe_id not in cwe_types:
                        cwe_types.append(cwe_id)

            # Extract description
            descriptions = cna.get("descriptions", [])
            description = descriptions[0].get("value", "") if descriptions else ""

            # Extract affected products
            affected = cna.get("affected", [])
            affected_products = [a.get("product", "") for a in affected if a.get("product")]

            # Extract references
            references_data = cna.get("references", [])
            references = [ref.get("url", "") for ref in references_data if ref.get("url")]

            # Extract published date
            published_date = cve_data.get("cveMetadata", {}).get("datePublished", "")

            if not cve_id:
                raise ValueError("CVE ID is missing or empty")

            return CVERecord(
                cve_id=cve_id,
                cwe_types=cwe_types,
                description=description,
                affected_products=affected_products,
                references=references,
                published_date=published_date,
            )

        except (KeyError, TypeError, AttributeError) as e:
            raise ValueError(f"Invalid CVE data structure: {e}") from e

    def matches_cwe_type(self, cwe_types: set[str]) -> bool:
        """
        Check if CVE has any CWE type in the provided set (including descendants).

        Args:
            cwe_types: Set of CWE identifiers to match

        Returns:
            True if any match found (exact or descendant), False otherwise
        """
        if not cwe_types:
            return False

        for cve_cwe in self.cwe_types:
            for target_cwe in cwe_types:
                # Exact match
                if cve_cwe == target_cwe:
                    return True
                # Check if cve_cwe is a descendant of target_cwe
                if _is_cwe_descendant(cve_cwe, target_cwe):
                    return True

        return False

    def matches_keywords(self, keywords: set[str]) -> bool:
        """
        Check if description or product contains any keyword (case-insensitive).

        Args:
            keywords: Keywords to search

        Returns:
            True if any match found, False otherwise
        """
        if not keywords:
            return False

        # Combine description and products for search
        text_to_search = f"{self.description} {' '.join(self.affected_products)}".lower()

        return any(keyword.lower() in text_to_search for keyword in keywords)


# Module-level cache for CWE descendant checks (CWE tree is static)
_cwe_descendant_cache: dict[tuple[str, str], bool] = {}


def _is_cwe_descendant(cwe_child: str, cwe_parent: str) -> bool:
    """
    Check if cwe_child is a descendant of cwe_parent in CWE hierarchy.

    Uses depth-first search via cwe-tree parent relationships with caching.

    Args:
        cwe_child: CWE to check if is descendant
        cwe_parent: CWE to check against

    Returns:
        True if cwe_child is a descendant of cwe_parent, False otherwise
    """
    cache_key = (cwe_child, cwe_parent)
    if cache_key in _cwe_descendant_cache:
        return _cwe_descendant_cache[cache_key]

    result = _check_descendant_dfs(cwe_child, cwe_parent, set())
    _cwe_descendant_cache[cache_key] = result
    return result


def _check_descendant_dfs(cwe_child: str, cwe_parent: str, visited: set[str]) -> bool:
    """DFS traversal to check descendant relationship."""
    if cwe_child in visited:
        return False

    visited.add(cwe_child)

    if cwe_child == cwe_parent:
        return True

    # Traverse up the hierarchy via parents
    parents = cwe_query.get_parents(cwe_child)
    for parent_id in parents:
        if _check_descendant_dfs(parent_id, cwe_parent, visited):
            return True

    return False


# ============================================================================
# GitHubPatch Class Hierarchy
# ============================================================================


@dataclass(frozen=True)
class GitHubPatch(ABC):
    """
    Abstract base class for GitHub patch URL types.

    Attributes:
        cve_id: Source CVE identifier
        repository: Repository in owner/repo format
    """

    cve_id: str
    repository: str

    @abstractmethod
    def get_patch_commit_sha(self) -> str:
        """
        Extract patch commit SHA from this patch instance.

        Returns:
            Commit SHA

        Raises:
            ValueError: SHA cannot be extracted
        """

    @abstractmethod
    def get_patch_files(self) -> list[str]:
        """
        Return list of file paths modified by this patch.

        Returns:
            List of file paths; empty if not applicable for this patch type
        """


@dataclass(frozen=True)
class CommitPatch(GitHubPatch):
    """
    Full commit patch (github.com/owner/repo/commit/sha).

    Attributes:
        commit_sha: Commit SHA
    """

    commit_sha: str

    def get_patch_commit_sha(self) -> str:
        """Return commit SHA directly."""
        if not self.commit_sha:
            raise ValueError("CommitPatch has empty commit_sha")
        return self.commit_sha

    def get_patch_files(self) -> list[str]:
        """Files must be queried from GitHub API."""
        return []


@dataclass(frozen=True)
class FilePatch(GitHubPatch):
    """
    File-specific patch (github.com/owner/repo/blob/sha/file/path).

    Attributes:
        commit_sha: Commit SHA
        file_path: File path at that commit
    """

    commit_sha: str
    file_path: str

    def get_patch_commit_sha(self) -> str:
        """Return commit SHA directly."""
        if not self.commit_sha:
            raise ValueError("FilePatch has empty commit_sha")
        return self.commit_sha

    def get_patch_files(self) -> list[str]:
        """Return single file path."""
        return [self.file_path] if self.file_path else []


@dataclass(frozen=True)
class PRPatch(GitHubPatch):
    """
    Pull request patch (github.com/owner/repo/pull/number).

    Attributes:
        pr_number: Pull request number
        merge_commit_sha: Merge commit SHA (resolved via GitHub API, may be None)
    """

    pr_number: int
    merge_commit_sha: str | None = None

    def get_patch_commit_sha(self) -> str:
        """Return merge commit SHA if available."""
        if not self.merge_commit_sha:
            raise ValueError(f"PRPatch #{self.pr_number} has no merge_commit_sha resolved")
        return self.merge_commit_sha

    def get_patch_files(self) -> list[str]:
        """Files must be queried from GitHub API."""
        return []


@dataclass(frozen=True)
class ComparePatch(GitHubPatch):
    """
    Compare/range patch (github.com/owner/repo/compare/sha1...sha2).

    Attributes:
        base_sha: Base commit SHA (sha1)
        head_sha: Head commit SHA (sha2, the patch commit)
    """

    base_sha: str
    head_sha: str

    def get_patch_commit_sha(self) -> str:
        """Return head SHA (right side of comparison, the patch)."""
        if not self.head_sha:
            raise ValueError("ComparePatch has empty head_sha")
        return self.head_sha

    def get_patch_files(self) -> list[str]:
        """Files must be queried from GitHub API (diff between base and head)."""
        return []
