"""
Matching Service for Business-Influencer Matching

This service handles:
1. Predicting business requirements using ML service
2. Calculating similarity between business requirements and influencer profiles
3. Ranking and returning top matches

This service is part of the businesses app and uses:
- core.services.MLClient for ML predictions
- influencers.models.InfluencerProfile for influencer data
"""

import logging
import numpy as np
from typing import List, Dict, Tuple, Optional

from influencers.models import InfluencerProfile
from core.services import MLClient

logger = logging.getLogger(__name__)


class MatchingService:
    """Service for matching businesses with influencers"""

    @staticmethod
    def _normalize_location_tokens(items: Optional[List[str]]) -> List[str]:
        if not items:
            return []
        out = []
        for x in items:
            if x is None:
                continue
            s = str(x).strip()
            if s:
                out.append(s.lower())
        return out

    @staticmethod
    def _audience_location_match_score(
        influencer: InfluencerProfile,
        wanted_countries: List[str],
        wanted_cities: List[str],
    ) -> float:
        """
        Score 0–1 from overlap between requested locations and influencer
        audience_top_countries / audience_top_cities (name + percentage rows).

        If nothing requested, returns 1.0 (neutral). Uses case-insensitive name
        match (exact or substring).
        """
        wc = MatchingService._normalize_location_tokens(wanted_countries)
        wct = MatchingService._normalize_location_tokens(wanted_cities)
        if not wc and not wct:
            return 1.0

        def best_row_score(wanted_norm: str, rows: list) -> float:
            if not rows or not wanted_norm:
                return 0.0
            best = 0.0
            for row in rows:
                if not isinstance(row, dict):
                    continue
                name = str(row.get('name') or '').strip().lower()
                if not name:
                    continue
                try:
                    pct = float(row.get('percentage') or 0)
                except (TypeError, ValueError):
                    pct = 0.0
                if wanted_norm == name or wanted_norm in name or name in wanted_norm:
                    best = max(best, pct)
            return min(1.0, best / 100.0)

        parts: List[float] = []
        if wc:
            rows_c = influencer.audience_top_countries or []
            parts.append(
                sum(best_row_score(w, rows_c) for w in wc) / len(wc)
            )
        if wct:
            rows_t = influencer.audience_top_cities or []
            parts.append(
                sum(best_row_score(w, rows_t) for w in wct) / len(wct)
            )
        if not parts:
            return 1.0
        return sum(parts) / len(parts)

    @staticmethod
    def generate_business_explanations(
        business_profile,
        niche_probs: Dict,
        popularity_probs: Dict
    ):
        """
        Generate human-readable explanations for business recommendations.

        Args:
            business_profile: BusinessProfile instance
            niche_probs: Niche probability distribution
            popularity_probs: Popularity probability distribution
        """
        # Get top predictions
        top_niche = max(niche_probs.items(), key=lambda x: x[1])[0] if niche_probs else None
        top_popularity = max(popularity_probs.items(), key=lambda x: x[1])[0] if popularity_probs else None

        # Save predictions
        business_profile.ai_predicted_niche = top_niche
        business_profile.ai_predicted_popularity = top_popularity
        business_profile.niche_probabilities = niche_probs
        business_profile.popularity_probabilities = popularity_probs

        # Generate niche explanation
        if top_niche and niche_probs:
            sorted_niches = sorted(niche_probs.items(), key=lambda x: x[1], reverse=True)[:3]
            top_prob = niche_probs.get(top_niche, 0)

            if len(sorted_niches) >= 2 and sorted_niches[1][1] >= 0.2:
                # Multiple strong niches
                secondary_niche = sorted_niches[1][0]
                secondary_prob = sorted_niches[1][1]
                business_profile.niche_recommendation_explanation = (
                    f"Based on your business description, we recommend {top_niche} influencers ({top_prob:.0%} match). "
                    f"We also found strong alignment with {secondary_niche} ({secondary_prob:.0%}), "
                    f"so consider exploring both niches for your campaign."
                )
            else:
                # Clear single niche
                business_profile.niche_recommendation_explanation = (
                    f"Based on your business description, we recommend {top_niche} influencers ({top_prob:.0%} match). "
                    f"Your products/services align well with {top_niche.lower()}-focused content creators."
                )

        # Generate tier explanation
        if top_popularity and popularity_probs:
            budget_min = business_profile.typical_budget_min or 0
            budget_max = business_profile.typical_budget_max or 0
            avg_budget = (budget_min + budget_max) / 2 if budget_max > 0 else budget_min

            tier_ranges = {
                'Nano': (10, 100),
                'Micro': (100, 1000),
                'Mid': (1000, 5000),
                'Macro': (5000, 25000),
                'Mega': (25000, float('inf'))
            }

            tier_min, tier_max = tier_ranges.get(top_popularity, (0, 0))

            if avg_budget >= tier_min and (tier_max == float('inf') or avg_budget <= tier_max):
                # Budget matches tier perfectly
                business_profile.tier_recommendation_explanation = (
                    f"Based on your budget (${budget_min:,.0f} - ${budget_max:,.0f}), we recommend {top_popularity} tier influencers. "
                    f"This tier typically charges ${tier_min:,.0f}+ per post, which aligns well with your budget."
                )
            elif avg_budget < tier_min:
                # Budget is below tier (might need to adjust)
                business_profile.tier_recommendation_explanation = (
                    f"Based on your budget (${budget_min:,.0f} - ${budget_max:,.0f}), we recommend {top_popularity} tier influencers. "
                    f"Note: This tier typically charges ${tier_min:,.0f}+ per post. Consider adjusting your budget or exploring lower tiers."
                )
            else:
                # Budget is above tier (can afford higher)
                business_profile.tier_recommendation_explanation = (
                    f"Based on your budget (${budget_min:,.0f} - ${budget_max:,.0f}), we recommend {top_popularity} tier influencers. "
                    f"Your budget could also support higher-tier influencers if you want greater reach."
                )

        business_profile.save(update_fields=[
            'ai_predicted_niche',
            'ai_predicted_popularity',
            'niche_probabilities',
            'popularity_probabilities',
            'niche_recommendation_explanation',
            'tier_recommendation_explanation'
        ])

    @staticmethod
    def find_best_matches(
        business_description: str,
        budget_min: float,
        budget_max: float,
        top_n: int = 10,
        niche_filter: str = None,
        min_followers: int = None,
        max_followers: int = None,
        business_prediction: Dict = None,
        audience_countries: Optional[List[str]] = None,
        audience_cities: Optional[List[str]] = None,
    ) -> List[Tuple[InfluencerProfile, Dict]]:
        """
        Find best influencer matches for business requirements
        
        Args:
            business_description: Description of the business
            budget_min: Minimum budget
            budget_max: Maximum budget
            top_n: Number of matches to return
            niche_filter: Optional niche to filter by
            min_followers: Optional minimum followers
            max_followers: Optional maximum followers
            business_prediction: Optional pre-calculated prediction
            
        Returns:
            List of (InfluencerProfile, match_info) tuples
        """
        
        # Step 1: Predict business requirements using ML (if not provided)
        if business_prediction:
            logger.info("Using provided business prediction (skipping ML service call)")
            business_probs = business_prediction
        else:
            logger.info(f"Predicting business requirements...")
            ml_client = MLClient()
            business_probs = ml_client.predict_business_requirements(
                business_description,
                budget_min,
                budget_max
            )
        
        if not business_probs:
            logger.error("ML service unavailable for business prediction")
            return []

        ac_countries = audience_countries or []
        ac_cities = audience_cities or []
        use_location = bool(
            MatchingService._normalize_location_tokens(ac_countries)
            or MatchingService._normalize_location_tokens(ac_cities)
        )

        # Step 2: Get influencers with ML predictions
        queryset = InfluencerProfile.objects.exclude(
            niche_probabilities={}
        ).exclude(
            popularity_probabilities={}
        ).filter(
            data_processing_status='COMPLETED'
        )
        
        # Apply filters if provided
        if niche_filter:
            queryset = queryset.filter(ai_predicted_niche=niche_filter)
        if min_followers:
            queryset = queryset.filter(followers_count__gte=min_followers)
        if max_followers:
            queryset = queryset.filter(followers_count__lte=max_followers)
        
        logger.info(f"Found {queryset.count()} influencers to match against")

        # Step 3: Two-stage filtering and ranking
        # Stage 1: Filter by niche only (remove tier filter to prioritize quality-based pricing)
        business_top_niche = MatchingService._get_top_class(
            business_probs.get('niche_probabilities', {})
        )
        business_top_popularity = MatchingService._get_top_class(
            business_probs.get('popularity_probabilities', {})
        )

        logger.info(f"Business requirements: niche='{business_top_niche}', popularity='{business_top_popularity}' (priority: niche only)")

        # Filter influencers by niche ONLY (not tier - we'll match by actual budget/pricing instead)
        filtered_influencers = []
        for influencer in queryset:
            influencer_top_niche = MatchingService._get_top_class(influencer.niche_probabilities)

            # Only include if niche matches
            if influencer_top_niche == business_top_niche:
                filtered_influencers.append(influencer)

        logger.info(f"Stage 1 filtering: {len(filtered_influencers)} influencers match niche (tier filter removed - quality-based pricing will determine fit)")

        # Stage 2: Calculate similarity for filtered influencers
        matches = []
        for influencer in filtered_influencers:
            similarity = MatchingService._calculate_similarity(
                business_probs,
                influencer.niche_probabilities,
                influencer.popularity_probabilities,
                budget_min,
                budget_max,
                float(influencer.estimated_price_min or 0),
                float(influencer.estimated_price_max or 0)
            )
            loc_score = MatchingService._audience_location_match_score(
                influencer, ac_countries, ac_cities
            )
            similarity['location_match_score'] = round(loc_score, 4)
            if use_location:
                # Niche + budget + audience geography
                total = (
                    0.40 * similarity['niche_score']
                    + 0.35 * similarity['budget_match_score']
                    + 0.25 * loc_score
                )
            else:
                total = (
                    0.50 * similarity['niche_score']
                    + 0.50 * similarity['budget_match_score']
                )
            similarity['total_score'] = round(total, 4)
            matches.append((influencer, similarity))

        # Step 4: Sort by match score and return top N
        matches.sort(key=lambda x: x[1]['total_score'], reverse=True)

        logger.info(f"Stage 2 ranking: Returning top {min(top_n, len(matches))} matches")

        return matches[:top_n]
    
    @staticmethod
    def _calculate_similarity(
        business_probs: Dict,
        influencer_niche_probs: Dict,
        influencer_pop_probs: Dict,
        budget_min: float,
        budget_max: float,
        influencer_price_min: float,
        influencer_price_max: float
    ) -> Dict:
        """
        Calculate similarity between business requirements and influencer

        UPDATED: Now uses budget-price overlap for better matching instead of just tier similarity

        Args:
            business_probs: Business prediction from ML service
            influencer_niche_probs: Influencer niche probabilities
            influencer_pop_probs: Influencer popularity probabilities
            budget_min: Business minimum budget
            budget_max: Business maximum budget
            influencer_price_min: Influencer minimum price
            influencer_price_max: Influencer maximum price

        Returns:
            {
                'niche_score': float (0-1),
                'budget_match_score': float (0-1),  # NEW: Budget-price overlap
                'tier_similarity_score': float (0-1),  # OLD: Tier similarity (kept for reference)
                'total_score': float (0-1),
                'business_niche_probs': dict,
                'business_popularity_probs': dict,
                'price_info': dict  # NEW: Price comparison details
            }
        """
        # Extract business probabilities
        business_niche_probs = business_probs.get('niche_probabilities', {})
        business_popularity_probs = business_probs.get('popularity_probabilities', {})

        # Calculate cosine similarity for niche
        niche_score = MatchingService._cosine_similarity(
            business_niche_probs,
            influencer_niche_probs
        )

        # Calculate tier similarity (OLD METHOD - kept for reference)
        tier_similarity_score = MatchingService._cosine_similarity(
            business_popularity_probs,
            influencer_pop_probs
        )

        # Calculate budget-price overlap (NEW METHOD - primary scoring)
        budget_match_score, price_info = MatchingService._calculate_budget_price_overlap(
            budget_min,
            budget_max,
            influencer_price_min,
            influencer_price_max
        )

        # Base components only; find_best_matches may blend location and overwrite total_score.
        total_score = (0.5 * niche_score) + (0.5 * budget_match_score)

        return {
            'niche_score': round(niche_score, 4),
            'budget_match_score': round(budget_match_score, 4),
            'tier_similarity_score': round(tier_similarity_score, 4),  # Kept for debugging
            'total_score': round(total_score, 4),
            'location_match_score': 1.0,
            'business_niche_probs': business_niche_probs,
            'business_popularity_probs': business_popularity_probs,
            'price_info': price_info
        }

    @staticmethod
    def _cosine_similarity(probs1: Dict, probs2: Dict) -> float:
        """
        Calculate cosine similarity between two probability distributions

        Args:
            probs1: First probability distribution
            probs2: Second probability distribution

        Returns:
            Cosine similarity score (0-1)
        """
        # Get all unique keys
        all_keys = sorted(set(probs1.keys()) | set(probs2.keys()))

        if not all_keys:
            return 0.0

        # Create vectors
        vec1 = np.array([probs1.get(k, 0.0) for k in all_keys])
        vec2 = np.array([probs2.get(k, 0.0) for k in all_keys])

        # Calculate cosine similarity
        dot_product = np.dot(vec1, vec2)
        norm1 = np.linalg.norm(vec1)
        norm2 = np.linalg.norm(vec2)

        if norm1 == 0 or norm2 == 0:
            return 0.0

        similarity = dot_product / (norm1 * norm2)

        # Ensure result is between 0 and 1
        return max(0.0, min(1.0, similarity))

    @staticmethod
    def _calculate_budget_price_overlap(
        budget_min: float,
        budget_max: float,
        price_min: float,
        price_max: float
    ) -> Tuple[float, Dict]:
        """
        Calculate overlap between business budget and influencer price range.

        This is MORE ACCURATE than tier matching because:
        - Business budget: $3,000 -> Matches influencers priced $2,000-$4,000
        - NOT just: Business "Mid tier" -> Influencer "Mid tier" (which could be $800 or $5,000!)

        Args:
            budget_min: Business minimum budget
            budget_max: Business maximum budget
            price_min: Influencer minimum price
            price_max: Influencer maximum price

        Returns:
            (overlap_score, price_info)
            overlap_score: 0-1 score based on overlap percentage
            price_info: {
                'overlap_amount': float,
                'budget_range': float,
                'price_range': float,
                'is_affordable': bool,
                'price_position': str  # 'within_budget', 'too_expensive', 'too_cheap'
            }
        """
        # Calculate overlap
        overlap_min = max(budget_min, price_min)
        overlap_max = min(budget_max, price_max)
        overlap_amount = max(0, overlap_max - overlap_min)

        # Calculate ranges
        budget_range = budget_max - budget_min
        price_range = price_max - price_min

        # Calculate score based on overlap percentage
        if budget_range > 0:
            overlap_score = overlap_amount / budget_range
        else:
            # If budget is a single value (min == max)
            overlap_score = 1.0 if overlap_amount > 0 else 0.0

        # Determine affordability
        is_affordable = price_min <= budget_max and price_max >= budget_min

        # Determine price position
        if price_max < budget_min:
            price_position = 'too_cheap'
        elif price_min > budget_max:
            price_position = 'too_expensive'
        else:
            price_position = 'within_budget'

        price_info = {
            'overlap_amount': round(overlap_amount, 2),
            'budget_range': round(budget_range, 2),
            'price_range': round(price_range, 2),
            'is_affordable': is_affordable,
            'price_position': price_position,
            'budget_min': round(budget_min, 2),
            'budget_max': round(budget_max, 2),
            'price_min': round(price_min, 2),
            'price_max': round(price_max, 2)
        }

        return overlap_score, price_info

    @staticmethod
    def _get_top_class(probabilities: Dict) -> str:
        """
        Get the class with highest probability

        Args:
            probabilities: Probability distribution dict

        Returns:
            Class name with highest probability
        """
        if not probabilities:
            return None
        return max(probabilities.items(), key=lambda x: x[1])[0]

    @staticmethod
    def match_influencers_to_business(
        business_niche_probs: Dict,
        business_popularity_probs: Dict,
        influencers,
        top_k: int = 10,
        use_two_stage_filtering: bool = True
    ) -> List[Dict]:
        """
        Match influencers to business requirements (used by API endpoint)

        Uses a two-stage approach:
        1. Filter influencers by top class (niche and popularity)
        2. Rank filtered influencers by probability similarity

        Args:
            business_niche_probs: Business niche probability distribution
            business_popularity_probs: Business popularity probability distribution
            influencers: QuerySet of InfluencerProfile objects
            top_k: Number of top matches to return
            use_two_stage_filtering: If True, filter by top class first (recommended)

        Returns:
            List of match dictionaries:
            [
                {
                    'influencer': InfluencerProfile,
                    'match_score': float,
                    'niche_similarity': float,
                    'popularity_similarity': float,
                    'top_niche_match': bool,
                    'top_popularity_match': bool
                },
                ...
            ]
        """
        # Stage 1: Filter by niche only (if enabled)
        if use_two_stage_filtering:
            # Get business's desired niche only (removed tier filter)
            business_top_niche = MatchingService._get_top_class(business_niche_probs)

            logger.info(f"Two-stage filtering enabled: Looking for niche='{business_top_niche}' (tier filter removed for quality-based matching)")

            # Filter influencers by niche ONLY
            filtered_influencers = []
            for influencer in influencers:
                influencer_top_niche = MatchingService._get_top_class(influencer.niche_probabilities)

                # Only include if niche matches
                if influencer_top_niche == business_top_niche:
                    filtered_influencers.append(influencer)

            logger.info(f"Filtered from {len(list(influencers))} to {len(filtered_influencers)} influencers matching niche (quality-based pricing will determine budget fit)")

            # Use filtered list for matching
            influencers_to_match = filtered_influencers
        else:
            # Use all influencers (old behavior)
            influencers_to_match = list(influencers)

        # Stage 2: Calculate similarity and rank
        matches = []
        for influencer in influencers_to_match:
            # Calculate niche similarity
            niche_similarity = MatchingService._cosine_similarity(
                business_niche_probs,
                influencer.niche_probabilities
            )

            # Calculate popularity similarity
            popularity_similarity = MatchingService._cosine_similarity(
                business_popularity_probs,
                influencer.popularity_probabilities
            )

            # Calculate overall match score (weighted average)
            match_score = (0.6 * niche_similarity) + (0.4 * popularity_similarity)

            # Check if top niche matches (for metadata)
            influencer_top_niche = MatchingService._get_top_class(influencer.niche_probabilities)
            business_top_niche = MatchingService._get_top_class(business_niche_probs)

            matches.append({
                'influencer': influencer,
                'match_score': round(match_score, 4),
                'niche_similarity': round(niche_similarity, 4),
                'popularity_similarity': round(popularity_similarity, 4),
                'top_niche_match': influencer_top_niche == business_top_niche,
            })

        # Sort by match score descending and return top K
        matches.sort(key=lambda x: x['match_score'], reverse=True)

        logger.info(f"Returning top {min(top_k, len(matches))} matches out of {len(matches)} candidates")

        return matches[:top_k]

