"""APM-style fingerprinting for intelligent exception grouping.

Inspired by Sentry/Rollbar approach to exception grouping:
- Normalize variable data in messages
- Extract function signatures from stack
- Generate stable fingerprints for similar errors
"""

import hashlib
import re
from typing import Dict, List, Optional

# Pre-compiled normalization patterns (order matters: specific before generic)
_NORMALIZE_PATTERNS = [
    (re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE), "<UUID>"),
    (re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", re.IGNORECASE), "<EMAIL>"),
    (re.compile(r"0x[0-9a-fA-F]+", re.IGNORECASE), "<ADDR>"),
    # Normalize quoted strings but preserve static identifier-like key names
    # (purely alphabetic+underscore, e.g., 'user_id', 'product_name').
    # Strings with digits/special chars are normalized as dynamic values.
    (re.compile(r"'(?![a-zA-Z_][a-zA-Z_]{0,49}')[^']*'"), "'<STR>'"),
    (re.compile(r'"(?![a-zA-Z_][a-zA-Z_]{0,49}")[^"]*"'), '"<STR>"'),
    (re.compile(r"/[^\s:,\]]+", re.IGNORECASE), "<PATH>"),
    (re.compile(r"at line \d+", re.IGNORECASE), "at line <N>"),
    (re.compile(r"position \d+", re.IGNORECASE), "position <N>"),
    (re.compile(r"column \d+", re.IGNORECASE), "column <N>"),
    (re.compile(r"\b\d+\b", re.IGNORECASE), "<NUM>"),
]

_STACK_FUNC_RE = re.compile(r"in (\w+|<\w+>)")
_STACK_FUNC_ALT_RE = re.compile(r":.*in (\w+|<\w+>)")


class ExceptionFingerprinter:
    """APM-style fingerprinting for intelligent exception grouping."""

    def fingerprint(
        self,
        exception_type: str,
        message: str,
        stack_trace: List[str],
        chained_type: Optional[str] = None,
        chained_message: Optional[str] = None,
    ) -> str:
        """Generate fingerprint for exception grouping.

        Priority order (like Sentry):
        1. Exception type
        2. Normalized message
        3. Top of stack (most specific)
        4. Chained exception info (if present)

        Args:
            exception_type: The exception class name
            message: The exception message
            stack_trace: List of stack frame strings
            chained_type: Type of chained exception (__cause__/__context__)
            chained_message: Message of chained exception

        Returns:
            16-character hex fingerprint
        """
        normalized_message = self._normalize_message(message)
        stack_signature = self._extract_stack_signature(stack_trace)

        fingerprint_data = f"{exception_type}:{normalized_message}:{stack_signature}"

        # Include chained exception for more specific fingerprints
        if chained_type:
            normalized_chained = self._normalize_message(chained_message or "")
            fingerprint_data += f":chain:{chained_type}:{normalized_chained}"

        return hashlib.sha256(fingerprint_data.encode()).hexdigest()[:16]

    def _normalize_message(self, message: str) -> str:
        """Normalize message by removing variable data.

        Replaces specific values with placeholders to group
        similar errors together.

        Note: Order matters! More specific patterns (UUIDs, emails)
        should come before generic patterns (numbers).
        """
        result = message
        for compiled_pattern, replacement in _NORMALIZE_PATTERNS:
            result = compiled_pattern.sub(replacement, result)

        return result

    def _extract_stack_signature(self, stack_trace: List[str]) -> str:
        """Extract signature from stack trace (function names only).

        Gets the last 3 user-code frames, excluding library code.
        """
        # Filter out library/framework frames
        user_frames = [
            frame
            for frame in stack_trace
            if "site-packages" not in frame
            and "<frozen" not in frame
            and "importlib" not in frame
        ]

        # Extract function names from last 3 frames
        func_names = []
        for frame in user_frames[-3:]:
            # Try to extract function name from frame
            # Format: "  File "/path/file.py", line 42, in function_name"
            match = _STACK_FUNC_RE.search(frame)
            if match:
                func_names.append(match.group(1))
            else:
                # Try alternate format: "file.py:42 in function_name"
                match = _STACK_FUNC_ALT_RE.search(frame)
                if match:
                    func_names.append(match.group(1))

        return ":".join(func_names) if func_names else "unknown"

    def extract_semantic_info(self, exception_type: str, message: str) -> Dict:
        """Extract structured info from error message.

        Parses exception messages to extract meaningful information
        like missing keys, type mismatches, etc.
        """
        extractors = {
            "KeyError": self._extract_keyerror_info,
            "TypeError": self._extract_typeerror_info,
            "AttributeError": self._extract_attributeerror_info,
            "ValueError": self._extract_valueerror_info,
            "IndexError": self._extract_indexerror_info,
            "NameError": self._extract_nameerror_info,
            "ImportError": self._extract_importerror_info,
            "ModuleNotFoundError": self._extract_importerror_info,
            "FileNotFoundError": self._extract_filenotfound_info,
            "JSONDecodeError": self._extract_jsonerror_info,
        }

        extractor = extractors.get(exception_type, self._extract_generic_info)
        return extractor(message)

    def _extract_keyerror_info(self, message: str) -> Dict:
        """Extract: KeyError: 'user_id' -> {"missing_key": "user_id"}"""
        match = re.search(r"['\"]([^'\"]+)['\"]", message)
        return {"missing_key": match.group(1) if match else None}

    def _extract_typeerror_info(self, message: str) -> Dict:
        """Extract type information from TypeError."""
        info: Dict[str, Optional[str]] = {}

        # "unsupported operand type(s) for +: 'int' and 'str'"
        operand_match = re.search(r"for (.+): '(\w+)' and '(\w+)'", message)
        if operand_match:
            info["operation"] = operand_match.group(1)
            info["left_type"] = operand_match.group(2)
            info["right_type"] = operand_match.group(3)
            return info

        # "'NoneType' object is not subscriptable/callable/iterable"
        none_match = re.search(r"'(\w+)' object is not (\w+)", message)
        if none_match:
            info["object_type"] = none_match.group(1)
            info["attempted_operation"] = none_match.group(2)
            return info

        # "function() missing N required positional argument(s)"
        missing_arg = re.search(
            r"(\w+)\(\) missing (\d+) required.*argument.*: (.+)", message
        )
        if missing_arg:
            info["function"] = missing_arg.group(1)
            info["missing_count"] = missing_arg.group(2)
            info["missing_args"] = missing_arg.group(3)
            return info

        # "function() takes N positional arguments but M were given"
        too_many = re.search(
            r"(\w+)\(\) takes (\d+) positional argument.* but (\d+) w", message
        )
        if too_many:
            info["function"] = too_many.group(1)
            info["expected_count"] = too_many.group(2)
            info["actual_count"] = too_many.group(3)
            return info

        # "cannot unpack non-iterable X object"
        unpack = re.search(r"cannot unpack non-iterable (\w+)", message)
        if unpack:
            info["non_iterable_type"] = unpack.group(1)
            return info

        return {"raw_message": message}

    def _extract_attributeerror_info(self, message: str) -> Dict:
        """Extract: 'NoneType' object has no attribute 'id'"""
        match = re.search(r"'(\w+)' object has no attribute '(\w+)'", message)
        if match:
            return {
                "object_type": match.group(1),
                "missing_attribute": match.group(2),
            }

        # "module 'x' has no attribute 'y'"
        module_match = re.search(r"module '(\w+)' has no attribute '(\w+)'", message)
        if module_match:
            return {
                "module_name": module_match.group(1),
                "missing_attribute": module_match.group(2),
            }

        return {"raw_message": message}

    def _extract_valueerror_info(self, message: str) -> Dict:
        """Extract value error info."""
        info: Dict[str, Optional[str]] = {}

        # "invalid literal for int() with base 10: 'abc'"
        literal_match = re.search(
            r"invalid literal for (\w+)\(\).*: ['\"](.+)['\"]", message
        )
        if literal_match:
            info["function"] = literal_match.group(1)
            info["invalid_value"] = literal_match.group(2)
            return info

        # "could not convert string to float: 'abc'"
        convert_match = re.search(r"could not convert (.+) to (\w+): (.+)", message)
        if convert_match:
            info["source_type"] = convert_match.group(1)
            info["target_type"] = convert_match.group(2)
            info["value"] = convert_match.group(3)
            return info

        # "X is not a valid Y"
        invalid_match = re.search(r"(.+) is not a valid (.+)", message)
        if invalid_match:
            info["value"] = invalid_match.group(1)
            info["expected_type"] = invalid_match.group(2)
            return info

        return {"raw_message": message}

    def _extract_indexerror_info(self, message: str) -> Dict:
        """Extract index error info."""
        # "list index out of range"
        if "list index out of range" in message:
            return {"container_type": "list"}
        if "tuple index out of range" in message:
            return {"container_type": "tuple"}
        if "string index out of range" in message:
            return {"container_type": "string"}
        return {"raw_message": message}

    def _extract_nameerror_info(self, message: str) -> Dict:
        """Extract: name 'x' is not defined"""
        match = re.search(r"name '(\w+)' is not defined", message)
        if match:
            return {"undefined_name": match.group(1)}
        return {"raw_message": message}

    def _extract_importerror_info(self, message: str) -> Dict:
        """Extract import error info."""
        # "No module named 'xyz'"
        module_match = re.search(r"No module named '([^']+)'", message)
        if module_match:
            return {"missing_module": module_match.group(1)}

        # "cannot import name 'X' from 'Y'"
        import_match = re.search(r"cannot import name '(\w+)' from '([^']+)'", message)
        if import_match:
            return {
                "missing_name": import_match.group(1),
                "module": import_match.group(2),
            }

        return {"raw_message": message}

    def _extract_filenotfound_info(self, message: str) -> Dict:
        """Extract file not found info."""
        # "[Errno 2] No such file or directory: '/path/to/file'"
        match = re.search(r"No such file or directory: ['\"](.+)['\"]", message)
        if match:
            return {"missing_path": match.group(1)}
        return {"raw_message": message}

    def _extract_jsonerror_info(self, message: str) -> Dict:
        """Extract JSON decode error info."""
        # "Expecting value: line 1 column 1"
        pos_match = re.search(r"line (\d+) column (\d+)", message)
        info: Dict[str, Optional[str]] = {}
        if pos_match:
            info["line"] = pos_match.group(1)
            info["column"] = pos_match.group(2)

        # Extract what was expected
        expect_match = re.search(r"Expecting ([^:]+):", message)
        if expect_match:
            info["expected"] = expect_match.group(1)

        return info if info else {"raw_message": message}

    def _extract_generic_info(self, message: str) -> Dict:
        """Fallback extractor for unknown exception types."""
        return {"raw_message": message}

    def get_similar_exceptions(
        self, fingerprint: str, all_fingerprints: List[str]
    ) -> List[str]:
        """Find exceptions with similar fingerprints.

        Uses prefix matching for grouping related errors.
        """
        # Match first 8 characters for similar grouping
        prefix = fingerprint[:8]
        return [fp for fp in all_fingerprints if fp.startswith(prefix) and fp != fingerprint]
