"""Business-aware optimization objectives for LeCrapaud.

This module provides classes for defining business costs and constraints
when training and optimizing machine learning models.

Example:
    >>> from lecrapaud import BusinessObjective, BusinessPresets
    >>> # Cost-based: Missing unicorn is 10x worse than reviewing bad deal
    >>> objective = BusinessObjective(fp_cost=1.0, fn_cost=10.0)
    >>> # Or use preset
    >>> objective = BusinessPresets.high_recall_screening(min_recall=0.95)
"""

from dataclasses import dataclass, field
from typing import Literal, Optional
import numpy as np


@dataclass
class BusinessObjective:
    """
    Define business-aware optimization objectives.

    Three modes of operation:
    1. Cost-based: Specify fp_cost and fn_cost to minimize total business cost
    2. Constraint-based: Fix one metric (e.g., min_recall=0.95), optimize another
    3. Threshold optimization: Find optimal decision threshold post-training

    Examples:
        # VC dealflow: Missing a unicorn is 10x worse than reviewing a bad deal
        objective = BusinessObjective(fp_cost=1.0, fn_cost=10.0)

        # Partner exposure: Must see 95% of good deals, maximize quality
        objective = BusinessObjective(min_recall=0.95, optimize="precision")

        # Fraud detection: Can't miss any fraud, accept some false alarms
        objective = BusinessObjective(min_recall=0.99, optimize="precision")

        # Final decision: Only show high-confidence predictions
        objective = BusinessObjective(min_precision=0.90, optimize="recall")

    Attributes:
        fp_cost: Cost of false positive (showing bad deal)
        fn_cost: Cost of false negative (missing good deal)
        min_recall: Minimum recall constraint
        min_precision: Minimum precision constraint
        optimize: Metric to optimize ("precision", "recall", "f1", "business_cost")
        optimize_threshold: Whether to find optimal threshold after training
        threshold_range: Range for threshold search
    """

    # Cost-based optimization
    fp_cost: float = 1.0  # Cost of false positive (showing bad deal)
    fn_cost: float = 1.0  # Cost of false negative (missing good deal)

    # Constraint-based optimization
    min_recall: Optional[float] = None  # Minimum recall constraint
    min_precision: Optional[float] = None  # Minimum precision constraint
    optimize: Literal["precision", "recall", "f1", "business_cost"] = "business_cost"

    # Threshold optimization
    optimize_threshold: bool = True  # Find optimal threshold after training
    threshold_range: tuple[float, float] = field(
        default_factory=lambda: (0.1, 0.9)
    )  # Search range

    def __post_init__(self):
        """Validate constraints after initialization."""
        # Validate constraints
        if self.min_recall is not None and self.min_precision is not None:
            raise ValueError(
                "Cannot set both min_recall and min_precision constraints"
            )

        # If constraint is set, default optimize to the other metric
        if self.min_recall is not None and self.optimize == "business_cost":
            self.optimize = "precision"
        if self.min_precision is not None and self.optimize == "business_cost":
            self.optimize = "recall"

        # Validate threshold range
        if self.threshold_range[0] >= self.threshold_range[1]:
            raise ValueError(
                "threshold_range[0] must be less than threshold_range[1]"
            )
        if not (0 <= self.threshold_range[0] <= 1) or not (
            0 <= self.threshold_range[1] <= 1
        ):
            raise ValueError("threshold_range values must be between 0 and 1")

    def compute_business_cost(self, y_true, y_pred) -> float:
        """
        Compute total business cost from predictions.

        Args:
            y_true: True labels (binary: 0 or 1)
            y_pred: Predicted labels (binary: 0 or 1)

        Returns:
            float: Total business cost (fp_cost * FP + fn_cost * FN)
        """
        y_true = np.asarray(y_true)
        y_pred = np.asarray(y_pred)

        fp = np.sum((y_pred == 1) & (y_true == 0))
        fn = np.sum((y_pred == 0) & (y_true == 1))

        return self.fp_cost * fp + self.fn_cost * fn

    def compute_metrics(self, y_true, y_prob, threshold: float = 0.5) -> dict:
        """
        Compute all relevant metrics at a given threshold.

        Args:
            y_true: True labels (binary: 0 or 1)
            y_prob: Predicted probabilities for the positive class
            threshold: Decision threshold

        Returns:
            dict: Dictionary with threshold, precision, recall, f1,
                  business_cost, tp, fp, tn, fn
        """
        y_true = np.asarray(y_true)
        y_prob = np.asarray(y_prob)
        y_pred = (y_prob >= threshold).astype(int)

        # Compute confusion matrix components
        tp = np.sum((y_pred == 1) & (y_true == 1))
        fp = np.sum((y_pred == 1) & (y_true == 0))
        tn = np.sum((y_pred == 0) & (y_true == 0))
        fn = np.sum((y_pred == 0) & (y_true == 1))

        # Compute metrics
        precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
        recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
        f1 = (
            2 * precision * recall / (precision + recall)
            if (precision + recall) > 0
            else 0.0
        )
        business_cost = self.fp_cost * fp + self.fn_cost * fn

        return {
            "threshold": threshold,
            "precision": precision,
            "recall": recall,
            "f1": f1,
            "business_cost": business_cost,
            "tp": int(tp),
            "fp": int(fp),
            "tn": int(tn),
            "fn": int(fn),
        }

    def find_optimal_threshold(self, y_true, y_prob) -> dict:
        """
        Find the optimal threshold based on business objective.

        Args:
            y_true: True labels (binary: 0 or 1)
            y_prob: Predicted probabilities for the positive class

        Returns:
            dict: Optimal threshold and metrics at that threshold
        """
        thresholds = np.linspace(
            self.threshold_range[0], self.threshold_range[1], 100
        )
        best_result = None
        best_score = float("-inf") if self.optimize != "business_cost" else float("inf")

        for thresh in thresholds:
            metrics = self.compute_metrics(y_true, y_prob, thresh)

            # Check constraints
            if self.min_recall is not None and metrics["recall"] < self.min_recall:
                continue
            if (
                self.min_precision is not None
                and metrics["precision"] < self.min_precision
            ):
                continue

            # Get optimization score
            if self.optimize == "business_cost":
                score = -metrics["business_cost"]  # Minimize cost
            else:
                score = metrics[self.optimize]

            if score > best_score:
                best_score = score
                best_result = metrics

        # If no threshold satisfies constraints, return the one closest to constraint
        if best_result is None:
            best_result = self._find_closest_valid_threshold(y_true, y_prob, thresholds)

        return best_result

    def _find_closest_valid_threshold(
        self, y_true, y_prob, thresholds: np.ndarray
    ) -> dict:
        """Find threshold closest to satisfying constraints."""
        best_result = None
        best_gap = float("inf")

        for thresh in thresholds:
            metrics = self.compute_metrics(y_true, y_prob, thresh)

            gap = 0.0
            if self.min_recall is not None:
                gap = max(0, self.min_recall - metrics["recall"])
            if self.min_precision is not None:
                gap = max(0, self.min_precision - metrics["precision"])

            if gap < best_gap:
                best_gap = gap
                best_result = metrics

        return best_result

    def get_eval_metric(self) -> str:
        """
        Get the primary metric for hyperopt optimization.

        Returns:
            str: Metric name for optimization
        """
        if self.optimize == "business_cost":
            return "business_cost"
        return self.optimize

    def get_metric_direction(self) -> str:
        """
        Get optimization direction.

        Returns:
            str: "minimize" for business_cost, "maximize" for other metrics
        """
        if self.optimize == "business_cost":
            return "minimize"
        return "maximize"

    def __repr__(self) -> str:
        """String representation."""
        parts = []
        if self.fp_cost != 1.0 or self.fn_cost != 1.0:
            parts.append(f"fp_cost={self.fp_cost}, fn_cost={self.fn_cost}")
        if self.min_recall is not None:
            parts.append(f"min_recall={self.min_recall}")
        if self.min_precision is not None:
            parts.append(f"min_precision={self.min_precision}")
        parts.append(f"optimize='{self.optimize}'")
        return f"BusinessObjective({', '.join(parts)})"


class BusinessPresets:
    """Common business objective presets."""

    @staticmethod
    def high_recall_screening(min_recall: float = 0.95) -> BusinessObjective:
        """
        Pre-screening: Don't miss any good opportunities.

        Use case: Dealflow pre-filter, fraud detection, medical screening.

        Args:
            min_recall: Minimum recall to maintain (default: 0.95)

        Returns:
            BusinessObjective configured for high recall with precision optimization
        """
        return BusinessObjective(min_recall=min_recall, optimize="precision")

    @staticmethod
    def high_precision_decision(min_precision: float = 0.90) -> BusinessObjective:
        """
        Final decision: Only act on high-confidence predictions.

        Use case: Partner exposure, automated actions, alerts.

        Args:
            min_precision: Minimum precision to maintain (default: 0.90)

        Returns:
            BusinessObjective configured for high precision with recall optimization
        """
        return BusinessObjective(min_precision=min_precision, optimize="recall")

    @staticmethod
    def cost_sensitive(fp_cost: float = 1.0, fn_cost: float = 1.0) -> BusinessObjective:
        """
        Cost-based: Minimize total business cost.

        Use case: When you can quantify the cost of each error type.

        Args:
            fp_cost: Cost of false positive
            fn_cost: Cost of false negative

        Returns:
            BusinessObjective configured for cost-based optimization
        """
        return BusinessObjective(
            fp_cost=fp_cost, fn_cost=fn_cost, optimize="business_cost"
        )

    @staticmethod
    def balanced() -> BusinessObjective:
        """
        Balanced: Optimize F1 (equal weight to precision and recall).

        Use case: No clear business preference, POC, benchmarking.

        Returns:
            BusinessObjective configured for F1 optimization
        """
        return BusinessObjective(optimize="f1")
