"""Cross-organization pattern aggregation with privacy.

Aggregates fix patterns across organizations while preserving
privacy through anonymization, differential privacy, and k-anonymity.
"""

from __future__ import annotations

import hashlib
import logging
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Protocol

logger = logging.getLogger(__name__)

from autodebug.learning.privacy import (
    DifferentialPrivacy,
    KAnonymityChecker,
    PrivacyConfig,
    hash_identifier,
)


@dataclass
class AnonymousPattern:
    """A pattern stripped of identifying information.

    Contains only structural information about the exception and fix,
    with all identifying data removed or hashed.
    """

    # Exception pattern
    exception_type: str
    message_pattern: str  # Regex pattern, not actual message
    framework: Optional[str] = None
    library: Optional[str] = None

    # Fix pattern
    fix_strategy: str = ""
    fix_pattern_hash: str = ""  # Hash of anonymized fix structure

    # Anonymous stack pattern
    stack_pattern: List[str] = field(default_factory=list)

    # Aggregated stats (with differential privacy applied at read time)
    global_success_count: int = 0
    global_failure_count: int = 0
    global_acceptance_rate: float = 0.0

    # Metadata
    first_seen: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    last_updated: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    org_count: int = 0  # Number of orgs that contributed
    is_shareable: bool = False  # True when k-anonymity met

    @property
    def pattern_key(self) -> str:
        """Unique key for this pattern."""
        return f"{self.exception_type}:{self.message_pattern}:{self.fix_strategy}:{self.fix_pattern_hash}"

    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for serialization."""
        return {
            "exception_type": self.exception_type,
            "message_pattern": self.message_pattern,
            "framework": self.framework,
            "library": self.library,
            "fix_strategy": self.fix_strategy,
            "fix_pattern_hash": self.fix_pattern_hash,
            "stack_pattern": self.stack_pattern,
            "global_success_count": self.global_success_count,
            "global_failure_count": self.global_failure_count,
            "global_acceptance_rate": self.global_acceptance_rate,
            "first_seen": self.first_seen.isoformat(),
            "last_updated": self.last_updated.isoformat(),
            "org_count": self.org_count,
            "is_shareable": self.is_shareable,
        }

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> AnonymousPattern:
        """Create from dictionary."""
        return cls(
            exception_type=data.get("exception_type", ""),
            message_pattern=data.get("message_pattern", ""),
            framework=data.get("framework"),
            library=data.get("library"),
            fix_strategy=data.get("fix_strategy", ""),
            fix_pattern_hash=data.get("fix_pattern_hash", ""),
            stack_pattern=data.get("stack_pattern", []),
            global_success_count=data.get("global_success_count", 0),
            global_failure_count=data.get("global_failure_count", 0),
            global_acceptance_rate=data.get("global_acceptance_rate", 0.0),
            first_seen=datetime.fromisoformat(data["first_seen"])
            if "first_seen" in data
            else datetime.now(timezone.utc),
            last_updated=datetime.fromisoformat(data["last_updated"])
            if "last_updated" in data
            else datetime.now(timezone.utc),
            org_count=data.get("org_count", 0),
            is_shareable=data.get("is_shareable", False),
        )


class GlobalPatternsDB(Protocol):
    """Protocol for global patterns database.

    Implementations handle actual storage (Supabase, PostgreSQL, etc.)
    """

    def find_pattern(
        self,
        exception_type: str,
        message_pattern: str,
        fix_strategy: str,
        fix_pattern_hash: str,
    ) -> Optional[AnonymousPattern]:
        """Find an existing pattern."""
        ...

    def find_patterns(
        self,
        exception_type: str,
        message_pattern: str,
        framework: Optional[str] = None,
        only_shareable: bool = True,
        min_success_rate: float = 0.0,
    ) -> List[AnonymousPattern]:
        """Find patterns matching criteria."""
        ...

    def insert_pattern(self, pattern: AnonymousPattern, org_hash: str) -> None:
        """Insert a new pattern."""
        ...

    def update_pattern(self, pattern: AnonymousPattern) -> None:
        """Update an existing pattern."""
        ...

    def org_contributed(self, pattern_hash: str, org_hash: str) -> bool:
        """Check if org already contributed to pattern."""
        ...

    def record_contribution(self, pattern_hash: str, org_hash: str) -> None:
        """Record that org contributed to pattern."""
        ...


class InMemoryPatternsDB:
    """In-memory implementation for testing."""

    def __init__(self):
        self.patterns: Dict[str, AnonymousPattern] = {}
        self.contributions: Dict[str, set] = {}

    def find_pattern(
        self,
        exception_type: str,
        message_pattern: str,
        fix_strategy: str,
        fix_pattern_hash: str,
    ) -> Optional[AnonymousPattern]:
        """Find an existing pattern."""
        key = f"{exception_type}:{message_pattern}:{fix_strategy}:{fix_pattern_hash}"
        return self.patterns.get(key)

    def find_patterns(
        self,
        exception_type: str,
        message_pattern: str,
        framework: Optional[str] = None,
        only_shareable: bool = True,
        min_success_rate: float = 0.0,
    ) -> List[AnonymousPattern]:
        """Find patterns matching criteria."""
        results = []
        for pattern in self.patterns.values():
            # Match exception type
            if pattern.exception_type != exception_type:
                continue

            # Match message pattern (fuzzy)
            if not self._patterns_similar(pattern.message_pattern, message_pattern):
                continue

            # Check shareable
            if only_shareable and not pattern.is_shareable:
                continue

            # Check success rate
            if pattern.global_acceptance_rate < min_success_rate:
                continue

            # Check framework if specified
            if framework and pattern.framework and pattern.framework != framework:
                continue

            results.append(pattern)

        return results

    def _patterns_similar(self, p1: str, p2: str) -> bool:
        """Check if two message patterns are similar."""
        # Exact match
        if p1 == p2:
            return True

        # Token overlap
        tokens1 = set(p1.split())
        tokens2 = set(p2.split())
        if not tokens1 or not tokens2:
            return p1 == p2

        overlap = len(tokens1 & tokens2) / max(len(tokens1), len(tokens2))
        return overlap > 0.5

    def insert_pattern(self, pattern: AnonymousPattern, org_hash: str) -> None:
        """Insert a new pattern."""
        key = pattern.pattern_key
        self.patterns[key] = pattern
        self.contributions[pattern.fix_pattern_hash] = {org_hash}

    def update_pattern(self, pattern: AnonymousPattern) -> None:
        """Update an existing pattern."""
        key = pattern.pattern_key
        self.patterns[key] = pattern

    def org_contributed(self, pattern_hash: str, org_hash: str) -> bool:
        """Check if org already contributed to pattern."""
        contribs = self.contributions.get(pattern_hash, set())
        return org_hash in contribs

    def record_contribution(self, pattern_hash: str, org_hash: str) -> None:
        """Record that org contributed to pattern."""
        if pattern_hash not in self.contributions:
            self.contributions[pattern_hash] = set()
        self.contributions[pattern_hash].add(org_hash)


class CrossOrgAggregator:
    """Aggregates patterns across organizations with privacy guarantees.

    Handles:
    - Anonymization of patterns before storage
    - K-anonymity verification before sharing
    - Differential privacy for aggregate statistics
    """

    # Patterns for sensitive values in messages
    # Order matters - more specific patterns first
    # These are applied with re.IGNORECASE in _extract_message_pattern.
    MESSAGE_PATTERNS = [
        # UUIDs first (before numbers strip them)
        (r'[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}', '<UUID>'),
        # Email addresses
        (r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', '<EMAIL>'),
        # IP addresses (v4) -- F-12
        (r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', '<IP>'),
        # Hostnames with 3+ dot-separated segments (e.g., prod-server-acme.corp.internal) -- F-12
        (r'\b[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?){2,}\b', '<HOSTNAME>'),
        # Hex addresses
        (r'0x[0-9a-f]+', '<ADDR>'),
        # Paths (Unix and Windows)
        (r'/[^\s\'\"]+', '<PATH>'),
        (r'\\[^\s\'\"]+', '<PATH>'),
        # Quoted strings (keys, identifiers)
        (r"'[^']+'" , "'<KEY>'"),
        (r'"[^"]+"', '"<KEY>"'),
        # Numbers last
        (r'\b\d+\b', '<NUM>'),
    ]

    # F-12: Case-sensitive patterns applied AFTER the main (case-insensitive)
    # patterns. These require case distinction and must NOT use re.IGNORECASE.
    MESSAGE_PATTERNS_CASE_SENSITIVE = [
        # CamelCase identifiers with 3+ humps (e.g., AcmeCorpClient) -- org-specific
        (r'\b[A-Z][a-z]+(?:[A-Z][a-z]+){2,}\b', '<CLASS>'),
        # UPPER_CASE_WITH_UNDERSCORES constants (e.g., ACME_PROD_DB_HOST)
        # Must contain at least one underscore to distinguish from normal words
        (r'\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b', '<CONST>'),
    ]

    # Known web frameworks
    FRAMEWORKS = {
        'django': ['django', 'wsgi'],
        'flask': ['flask', 'werkzeug'],
        'fastapi': ['fastapi', 'starlette'],
        'tornado': ['tornado'],
        'aiohttp': ['aiohttp'],
    }

    # Known libraries
    LIBRARIES = ['requests', 'httpx', 'sqlalchemy', 'pydantic', 'redis', 'celery', 'boto3']

    def __init__(
        self,
        global_db: GlobalPatternsDB,
        config: Optional[PrivacyConfig] = None,
    ):
        """Initialize cross-org aggregator.

        Args:
            global_db: Database for storing global patterns
            config: Privacy configuration
        """
        self.global_db = global_db
        self.config = config or PrivacyConfig()
        self.dp = DifferentialPrivacy(self.config)
        self.k_checker = KAnonymityChecker(self.config.k_anonymity)

    def contribute_pattern(
        self,
        org_id: str,
        exception_type: str,
        exception_message: str,
        stack_trace: List[str],
        fix_strategy: str,
        was_accepted: bool,
    ) -> bool:
        """Bridge method called by ActiveLearner.

        Converts keyword args to pattern_data dict and delegates
        to submit_pattern.

        Args:
            org_id: Organization identifier
            exception_type: Type of exception
            exception_message: Exception message
            stack_trace: Stack trace lines
            fix_strategy: Strategy used for the fix
            was_accepted: Whether the fix was accepted

        Returns:
            True if pattern was accepted
        """
        # Check opt-out
        if self._is_opted_out(org_id):
            return False

        pattern_data = {
            "exception_type": exception_type,
            "exception_message": exception_message,
            "stack_trace": stack_trace,
            "strategy": fix_strategy,
            "success": was_accepted,
        }
        return self.submit_pattern(org_id, pattern_data)

    def set_opt_out(self, org_id: str, opt_out: bool = True) -> None:
        """Set opt-out status for an organization.

        Args:
            org_id: Organization identifier
            opt_out: True to opt out, False to opt back in
        """
        org_hash = hash_identifier(org_id, salt="hikaflow_org")
        if not hasattr(self, '_opted_out_orgs'):
            self._opted_out_orgs: set = set()
        if opt_out:
            self._opted_out_orgs.add(org_hash)
        else:
            self._opted_out_orgs.discard(org_hash)

    def _is_opted_out(self, org_id: str) -> bool:
        """Check if an organization has opted out.

        Args:
            org_id: Organization identifier

        Returns:
            True if opted out
        """
        if not hasattr(self, '_opted_out_orgs'):
            self._opted_out_orgs: set = set()
            return False
        org_hash = hash_identifier(org_id, salt="hikaflow_org")
        return org_hash in self._opted_out_orgs

    def submit_pattern(self, org_id: str, pattern_data: Dict[str, Any]) -> bool:
        """Submit a pattern from an organization.

        Args:
            org_id: Organization identifier (will be hashed)
            pattern_data: Pattern data to submit

        Returns:
            True if pattern was accepted
        """
        # Privacy warning: cross-org data is being aggregated centrally.
        # This is NOT federated learning -- raw pattern data is sent to
        # the central aggregator where it is anonymized before storage.
        # Ensure anonymization is applied before any persistence.
        logger.warning(
            "PRIVACY: Cross-org pattern aggregation is centralized, not federated. "
            "Data from org '%s' will be anonymized before storage but is transmitted "
            "to the central aggregator in its original form. Consider enabling "
            "client-side anonymization for stronger privacy guarantees.",
            hash_identifier(org_id, salt="hikaflow_org")[:8],
        )

        # Anonymize the pattern BEFORE any further processing
        anonymous = self._anonymize_pattern(pattern_data)

        # Verify anonymization was applied: reject if raw message leaked through
        raw_message = pattern_data.get("exception_message", "")
        if raw_message and anonymous.message_pattern == raw_message and len(raw_message) > 50:
            logger.error(
                "PRIVACY: Anonymization may not have been applied to exception message. "
                "Rejecting pattern to prevent data leakage."
            )
            return False

        # Hash org ID for privacy
        org_hash = hash_identifier(org_id, salt="hikaflow_org")

        # Check if pattern already exists
        existing = self._find_matching_pattern(anonymous)

        anonymized_data = {
            "success": pattern_data.get("success"),
            "exception_type": anonymous.exception_type,
            "message_pattern": anonymous.message_pattern,
            "fix_pattern_hash": anonymous.fix_pattern_hash,
        }

        if existing:
            self._update_pattern(existing, anonymized_data, org_hash)
        else:
            self._create_pattern(anonymous, anonymized_data, org_hash)

        return True

    def _anonymize_pattern(self, data: Dict[str, Any]) -> AnonymousPattern:
        """Strip identifying information from pattern.

        Args:
            data: Raw pattern data

        Returns:
            Anonymized pattern
        """
        stack_trace = data.get("stack_trace", [])

        return AnonymousPattern(
            exception_type=data.get("exception_type", ""),
            message_pattern=self._extract_message_pattern(
                data.get("exception_message", "")
            ),
            framework=self._detect_framework(stack_trace),
            library=self._detect_library(stack_trace),
            fix_strategy=data.get("strategy", "unknown"),
            fix_pattern_hash=self._hash_fix_pattern(data.get("fix_diff", "")),
            stack_pattern=self._anonymize_stack(stack_trace),
            global_success_count=1 if data.get("success") else 0,
            global_failure_count=0 if data.get("success") else 1,
            global_acceptance_rate=1.0 if data.get("success") else 0.0,
            first_seen=datetime.now(timezone.utc),
            last_updated=datetime.now(timezone.utc),
            org_count=1,
            is_shareable=False,  # Not shareable until k-anonymity met
        )

    def _extract_message_pattern(self, message: str) -> str:
        """Convert specific message to pattern.

        Replaces specific values with placeholders to anonymize.

        Args:
            message: Original exception message

        Returns:
            Anonymized message pattern
        """
        result = message

        # Apply case-insensitive patterns first (UUIDs, emails, paths, etc.)
        for pattern, replacement in self.MESSAGE_PATTERNS:
            result = re.sub(pattern, replacement, result, flags=re.IGNORECASE)

        # F-12: Apply case-sensitive patterns (CamelCase, UPPER_CASE)
        # These MUST NOT use re.IGNORECASE or they will over-match.
        for pattern, replacement in self.MESSAGE_PATTERNS_CASE_SENSITIVE:
            result = re.sub(pattern, replacement, result)

        return result

    def _hash_fix_pattern(self, diff: str) -> str:
        """Create anonymous hash of fix structure.

        Args:
            diff: Git diff of the fix

        Returns:
            16-character hash of fix structure
        """
        if not diff:
            return "0" * 16

        structural = []

        for line in diff.split('\n'):
            if line.startswith('+') and not line.startswith('+++'):
                anon_line = self._anonymize_code_line(line[1:])
                structural.append(f"+{anon_line}")
            elif line.startswith('-') and not line.startswith('---'):
                anon_line = self._anonymize_code_line(line[1:])
                structural.append(f"-{anon_line}")

        # Sort for consistency
        pattern = '\n'.join(sorted(structural))
        return hashlib.sha256(pattern.encode()).hexdigest()[:16]

    def _anonymize_code_line(self, line: str) -> str:
        """Anonymize a code line to its structural pattern.

        F-12: Now catches CamelCase class names and UPPER_CASE constants
        in addition to lowercase variable names.

        Args:
            line: Code line

        Returns:
            Structural pattern of the line
        """
        patterns = [
            # CamelCase class/type names (must come before lowercase)
            (r'\b[A-Z][a-zA-Z0-9]*\b', 'CLS'),
            # UPPER_CASE constants
            (r'\b[A-Z][A-Z0-9_]{2,}\b', 'CONST'),
            # Lowercase identifiers (variables, functions)
            (r'\b[a-z_][a-z0-9_]*\b', 'VAR'),
            # String literals
            (r"'[^']*'", 'STR'),
            (r'"[^"]*"', 'STR'),
            # Numeric literals
            (r'\b\d+\b', 'NUM'),
        ]

        result = line.strip()
        for pattern, replacement in patterns:
            result = re.sub(pattern, replacement, result)

        return result

    def _anonymize_stack(self, stack_trace: List[str]) -> List[str]:
        """Extract anonymous function call pattern from stack.

        Args:
            stack_trace: Stack trace lines

        Returns:
            List of anonymized function names
        """
        pattern = []

        for frame in stack_trace:
            # Extract function name from frame
            if ' in ' in frame:
                func_name = frame.split(' in ')[-1].strip()
                func_name = self._generalize_function_name(func_name)
                pattern.append(func_name)
            elif '.' in frame:
                # Try to extract method name
                parts = frame.split('.')
                if parts:
                    func_name = self._generalize_function_name(parts[-1])
                    pattern.append(func_name)

        # Keep only last 5 frames
        return pattern[-5:]

    def _generalize_function_name(self, name: str) -> str:
        """Generalize function name to pattern.

        Args:
            name: Function name

        Returns:
            Generalized pattern
        """
        # Common patterns to generalize
        patterns = {
            r'get_\w+_by_\w+': 'get_MODEL_by_FIELD',
            r'create_\w+': 'create_MODEL',
            r'update_\w+': 'update_MODEL',
            r'delete_\w+': 'delete_MODEL',
            r'test_\w+': 'test_CASE',
            r'handle_\w+': 'handle_EVENT',
            r'process_\w+': 'process_ITEM',
            r'validate_\w+': 'validate_ITEM',
            r'save_\w+': 'save_MODEL',
            r'load_\w+': 'load_MODEL',
        }

        for pattern, replacement in patterns.items():
            if re.match(pattern, name, re.IGNORECASE):
                return replacement

        return name

    def _detect_framework(self, stack_trace: List[str]) -> Optional[str]:
        """Detect framework from stack trace.

        Args:
            stack_trace: Stack trace lines

        Returns:
            Framework name or None
        """
        stack_str = ' '.join(stack_trace).lower()

        for framework, indicators in self.FRAMEWORKS.items():
            if any(ind in stack_str for ind in indicators):
                return framework

        return None

    def _detect_library(self, stack_trace: List[str]) -> Optional[str]:
        """Detect library from stack trace.

        Args:
            stack_trace: Stack trace lines

        Returns:
            Library name or None
        """
        stack_str = ' '.join(stack_trace).lower()

        for lib in self.LIBRARIES:
            if lib in stack_str:
                return lib

        return None

    def _find_matching_pattern(
        self, pattern: AnonymousPattern
    ) -> Optional[AnonymousPattern]:
        """Find existing pattern matching this one.

        Args:
            pattern: Pattern to match

        Returns:
            Matching pattern or None
        """
        return self.global_db.find_pattern(
            exception_type=pattern.exception_type,
            message_pattern=pattern.message_pattern,
            fix_strategy=pattern.fix_strategy,
            fix_pattern_hash=pattern.fix_pattern_hash,
        )

    def _create_pattern(
        self, pattern: AnonymousPattern, data: Dict[str, Any], org_hash: str
    ) -> None:
        """Create new pattern in database.

        Args:
            pattern: Anonymized pattern
            data: Original pattern data
            org_hash: Hashed organization ID
        """
        self.global_db.insert_pattern(pattern, org_hash)

    def _update_pattern(
        self,
        existing: AnonymousPattern,
        data: Dict[str, Any],
        org_hash: str,
    ) -> None:
        """Update existing pattern with new contribution.

        Args:
            existing: Existing pattern
            data: New pattern data
            org_hash: Hashed organization ID
        """
        # Check if this org already contributed
        already_contributed = self.global_db.org_contributed(
            existing.fix_pattern_hash, org_hash
        )

        if not already_contributed:
            existing.org_count += 1
            self.global_db.record_contribution(existing.fix_pattern_hash, org_hash)

        # Update counts
        if data.get("success"):
            existing.global_success_count += 1
        else:
            existing.global_failure_count += 1

        # Recalculate rate
        total = existing.global_success_count + existing.global_failure_count
        if total > 0:
            existing.global_acceptance_rate = existing.global_success_count / total

        existing.last_updated = datetime.now(timezone.utc)

        # Check if now shareable (k-anonymity met)
        if not existing.is_shareable:
            existing.is_shareable = existing.org_count >= self.config.k_anonymity

        self.global_db.update_pattern(existing)

    def get_patterns_for_exception(
        self,
        exception_type: str,
        message_pattern: str,
        framework: Optional[str] = None,
    ) -> List[AnonymousPattern]:
        """Get shareable patterns for an exception type.

        Applies differential privacy to counts before returning.
        F-16: Checks privacy budget before answering; returns empty
        list if budget is exhausted to prevent statistical attacks.

        Args:
            exception_type: Type of exception
            message_pattern: Anonymized message pattern
            framework: Optional framework filter

        Returns:
            List of matching patterns with noisy statistics
        """
        # F-16: Check privacy budget before serving any patterns.
        # Without this check, unlimited queries would allow averaging
        # out DP noise to recover true counts.
        if not self.dp.check_budget():
            logger.warning(
                "Privacy budget exhausted — refusing pattern query "
                "to preserve differential privacy guarantees"
            )
            return []

        patterns = self.global_db.find_patterns(
            exception_type=exception_type,
            message_pattern=message_pattern,
            framework=framework,
            only_shareable=True,
            min_success_rate=0.5,
        )

        # Apply differential privacy to counts before returning.
        # Each call to add_noise_to_count/add_noise_to_rate now
        # spends budget via _spend_budget (fixed in privacy.py).
        noised_patterns = []
        for pattern in patterns:
            try:
                pattern.global_success_count = self.dp.add_noise_to_count(
                    pattern.global_success_count
                )
                pattern.global_failure_count = self.dp.add_noise_to_count(
                    pattern.global_failure_count
                )
                total = pattern.global_success_count + pattern.global_failure_count
                pattern.global_acceptance_rate = self.dp.add_noise_to_rate(
                    pattern.global_acceptance_rate, total
                )
                noised_patterns.append(pattern)
            except Exception:
                # Budget exhausted mid-query; stop adding more patterns
                logger.warning("Privacy budget exhausted mid-query, returning partial results")
                break

        return sorted(noised_patterns, key=lambda p: p.global_acceptance_rate, reverse=True)

    def get_strategy_recommendations(
        self, exception_type: str, message: str
    ) -> List[str]:
        """Get recommended strategies for an exception.

        Args:
            exception_type: Type of exception
            message: Exception message

        Returns:
            List of recommended strategy names
        """
        message_pattern = self._extract_message_pattern(message)
        patterns = self.get_patterns_for_exception(exception_type, message_pattern)

        # Extract unique strategies, ordered by acceptance rate
        strategies = []
        seen = set()
        for pattern in patterns:
            if pattern.fix_strategy and pattern.fix_strategy not in seen:
                strategies.append(pattern.fix_strategy)
                seen.add(pattern.fix_strategy)

        return strategies[:5]  # Top 5 strategies
