#!/usr/bin/env python3
"""
Lambda Function Enricher - Decommission signal analysis for AWS Lambda functions.

Decommission Signals (LM1-LM5):
- LM1: No invocations in 90+ days
- LM2: Error rate > 50% (CloudWatch Errors / Invocations)
- LM3: Cold start > 5 s (CloudWatch Duration P99)
- LM4: No tags (ungoverned)
- LM5: Deprecated runtime (python3.8, nodejs14.x, etc.)

Usage:
    from runbooks.inventory.enrichers.lambda_enricher import LambdaEnricher

    enricher = LambdaEnricher(profile="ops-profile", region="ap-southeast-2")
    results = enricher.analyze()
    enricher.display_results(results)
"""

import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Any, Dict, List, Optional

import pandas as pd
from botocore.exceptions import ClientError

from runbooks.common.profile_utils import (
    create_operational_session,
    create_timeout_protected_client,
    get_profile_for_operation,
)
from runbooks.common.rich_utils import (
    console,
    create_table,
    print_error,
    print_info,
    print_success,
    print_warning,
)

logger = logging.getLogger(__name__)

# ─────────────────────────────────────────────────────────────────────────────
# CONSTANTS
# ─────────────────────────────────────────────────────────────────────────────

INVOCATION_LOOKBACK_DAYS = 90
COLD_START_THRESHOLD_MS = 5_000.0  # 5 seconds expressed in milliseconds
ERROR_RATE_THRESHOLD = 0.50  # 50%

# AWS Lambda runtimes that have reached end-of-life / deprecated
EOL_RUNTIMES: frozenset = frozenset(
    [
        "python3.6",
        "python3.7",
        "python3.8",
        "nodejs10.x",
        "nodejs12.x",
        "nodejs14.x",
        "ruby2.5",
        "ruby2.7",
        "java8",
        "dotnetcore1.0",
        "dotnetcore2.0",
        "dotnetcore2.1",
        "dotnetcore3.1",
        "go1.x",
    ]
)


# ─────────────────────────────────────────────────────────────────────────────
# ENUMERATIONS
# ─────────────────────────────────────────────────────────────────────────────


class LambdaSignal(str, Enum):
    """Lambda decommission signals (LM1-LM5)."""

    LM1_NO_INVOCATIONS = "LM1"  # No invocations in 90+ days
    LM2_HIGH_ERRORS = "LM2"  # Error rate > 50%
    LM3_SLOW_COLD_START = "LM3"  # Duration P99 > 5 s
    LM4_NO_TAGS = "LM4"  # No resource tags
    LM5_DEPRECATED_RUNTIME = "LM5"  # Runtime at end-of-life


class LambdaRecommendation(str, Enum):
    """Recommended action for a Lambda function."""

    DECOMMISSION = "DECOMMISSION"  # High confidence
    INVESTIGATE = "INVESTIGATE"  # Medium confidence
    KEEP = "KEEP"  # No decommission signals


# ─────────────────────────────────────────────────────────────────────────────
# DATA MODEL
# ─────────────────────────────────────────────────────────────────────────────


@dataclass
class LambdaMetrics:
    """CloudWatch metrics for a Lambda function."""

    total_invocations_90d: float = 0.0
    total_errors_90d: float = 0.0
    error_rate: float = 0.0  # 0.0 – 1.0
    duration_p99_ms: float = 0.0


@dataclass
class LambdaAnalysis:
    """Analysis result for a single Lambda function."""

    function_name: str
    runtime: str
    last_modified: str
    memory_mb: int
    timeout_sec: int
    tags: Dict[str, str]
    metrics: LambdaMetrics
    signals: List[LambdaSignal] = field(default_factory=list)
    recommendation: LambdaRecommendation = LambdaRecommendation.KEEP

    def to_dict(self) -> Dict[str, Any]:
        return {
            "function_name": self.function_name,
            "runtime": self.runtime,
            "memory_mb": self.memory_mb,
            "timeout_sec": self.timeout_sec,
            "invocations_90d": self.metrics.total_invocations_90d,
            "error_rate_pct": round(self.metrics.error_rate * 100, 1),
            "duration_p99_ms": round(self.metrics.duration_p99_ms, 0),
            "signals": [s.value for s in self.signals],
            "recommendation": self.recommendation.value,
        }


# ─────────────────────────────────────────────────────────────────────────────
# ENRICHER CLASS
# ─────────────────────────────────────────────────────────────────────────────


class LambdaEnricher:
    """
    Lambda function enricher with LM1-LM5 decommission signals.

    Discovers Lambda functions, pulls 90-day CloudWatch metrics, and scores
    each function against five decommission signals for CloudOps review.
    """

    def __init__(
        self,
        profile: Optional[str] = None,
        region: Optional[str] = None,
    ) -> None:
        """
        Initialise with boto3 Lambda + CloudWatch clients.

        Args:
            profile: AWS profile name (defaults to operational profile)
            region: AWS region (defaults to ap-southeast-2)
        """
        self.profile = get_profile_for_operation("operational", profile)
        self.region = region or "ap-southeast-2"
        self.session = create_operational_session(self.profile)
        self.lambda_client = create_timeout_protected_client(self.session, "lambda", region_name=self.region)
        self.cloudwatch = create_timeout_protected_client(self.session, "cloudwatch", region_name=self.region)
        logger.debug("LambdaEnricher initialised: profile=%s region=%s", self.profile, self.region)

    # ── Discovery ────────────────────────────────────────────────────────────

    def _list_functions(self) -> List[Dict[str, Any]]:
        """Return all Lambda functions in the region via paginator."""
        functions: List[Dict[str, Any]] = []
        try:
            paginator = self.lambda_client.get_paginator("list_functions")
            for page in paginator.paginate():
                functions.extend(page.get("Functions", []))
        except ClientError as exc:
            print_error(f"list_functions failed: {exc}")
        return functions

    def _get_tags(self, function_arn: str) -> Dict[str, str]:
        """Return tags dict for a Lambda function ARN."""
        try:
            resp = self.lambda_client.list_tags(Resource=function_arn)
            return resp.get("Tags", {})
        except ClientError:
            return {}

    # ── CloudWatch metric helpers ─────────────────────────────────────────────

    def _get_metric_sum(self, function_name: str, metric_name: str, days: int) -> float:
        """
        Return SUM statistic for a Lambda CloudWatch metric over ``days`` days.

        Returns 0.0 on any error (graceful degradation).
        """
        end = datetime.now(tz=timezone.utc)
        start = end - timedelta(days=days)
        try:
            resp = self.cloudwatch.get_metric_statistics(
                Namespace="AWS/Lambda",
                MetricName=metric_name,
                Dimensions=[{"Name": "FunctionName", "Value": function_name}],
                StartTime=start,
                EndTime=end,
                Period=days * 86400,  # single data point covering full window
                Statistics=["Sum"],
            )
            datapoints = resp.get("Datapoints", [])
            if not datapoints:
                return 0.0
            return float(datapoints[0].get("Sum", 0.0))
        except ClientError as exc:
            logger.debug("get_metric_statistics(%s, %s) failed: %s", function_name, metric_name, exc)
            return 0.0

    def _get_duration_p99(self, function_name: str, days: int) -> float:
        """
        Return 99th-percentile Duration (ms) over ``days`` days.

        Uses ExtendedStatistics — gracefully degrades to 0.0 on error.
        """
        end = datetime.now(tz=timezone.utc)
        start = end - timedelta(days=days)
        try:
            resp = self.cloudwatch.get_metric_statistics(
                Namespace="AWS/Lambda",
                MetricName="Duration",
                Dimensions=[{"Name": "FunctionName", "Value": function_name}],
                StartTime=start,
                EndTime=end,
                Period=days * 86400,
                ExtendedStatistics=["p99"],
            )
            datapoints = resp.get("Datapoints", [])
            if not datapoints:
                return 0.0
            return float(datapoints[0].get("ExtendedStatistics", {}).get("p99", 0.0))
        except ClientError as exc:
            logger.debug("Duration p99(%s) failed: %s", function_name, exc)
            return 0.0

    # ── Analysis ─────────────────────────────────────────────────────────────

    def analyze(self, resource_ids: Optional[List[str]] = None) -> List[LambdaAnalysis]:
        """
        Discover and analyse Lambda functions.

        Args:
            resource_ids: Optional list of function names to limit analysis.
                          Analyses all functions in the region when None.

        Returns:
            List of LambdaAnalysis with LM1-LM5 signals populated.
        """
        print_info(f"Fetching Lambda functions (region={self.region})…")
        raw = self._list_functions()
        if resource_ids:
            name_set = frozenset(resource_ids)
            raw = [f for f in raw if f["FunctionName"] in name_set]

        if not raw:
            print_warning("No Lambda functions found.")
            return []

        print_info(f"Analysing {len(raw)} Lambda functions…")
        results: List[LambdaAnalysis] = []

        for fn in raw:
            function_name = fn["FunctionName"]
            runtime = fn.get("Runtime", "unknown")
            try:
                # Retrieve tags
                tags = self._get_tags(fn["FunctionArn"])

                # CloudWatch metrics
                invocations = self._get_metric_sum(function_name, "Invocations", INVOCATION_LOOKBACK_DAYS)
                errors = self._get_metric_sum(function_name, "Errors", INVOCATION_LOOKBACK_DAYS)
                duration_p99 = self._get_duration_p99(function_name, INVOCATION_LOOKBACK_DAYS)

                error_rate = (errors / invocations) if invocations > 0 else 0.0

                metrics = LambdaMetrics(
                    total_invocations_90d=invocations,
                    total_errors_90d=errors,
                    error_rate=error_rate,
                    duration_p99_ms=duration_p99,
                )

                # Signal evaluation
                signals: List[LambdaSignal] = []

                # LM1: No invocations in 90 days
                if invocations == 0:
                    signals.append(LambdaSignal.LM1_NO_INVOCATIONS)

                # LM2: Error rate > 50%
                if invocations > 0 and error_rate > ERROR_RATE_THRESHOLD:
                    signals.append(LambdaSignal.LM2_HIGH_ERRORS)

                # LM3: Cold start > 5 s (Duration P99 > threshold)
                if duration_p99 > COLD_START_THRESHOLD_MS:
                    signals.append(LambdaSignal.LM3_SLOW_COLD_START)

                # LM4: No tags
                if not tags:
                    signals.append(LambdaSignal.LM4_NO_TAGS)

                # LM5: Deprecated runtime
                if runtime in EOL_RUNTIMES:
                    signals.append(LambdaSignal.LM5_DEPRECATED_RUNTIME)

                # Recommendation
                high_signals = {LambdaSignal.LM1_NO_INVOCATIONS, LambdaSignal.LM5_DEPRECATED_RUNTIME}
                if any(s in high_signals for s in signals) and len(signals) >= 2:
                    recommendation = LambdaRecommendation.DECOMMISSION
                elif signals:
                    recommendation = LambdaRecommendation.INVESTIGATE
                else:
                    recommendation = LambdaRecommendation.KEEP

                results.append(
                    LambdaAnalysis(
                        function_name=function_name,
                        runtime=runtime,
                        last_modified=fn.get("LastModified", ""),
                        memory_mb=fn.get("MemorySize", 128),
                        timeout_sec=fn.get("Timeout", 3),
                        tags=tags,
                        metrics=metrics,
                        signals=signals,
                        recommendation=recommendation,
                    )
                )

            except Exception as exc:
                logger.error("Failed to analyse function %s: %s", function_name, exc, exc_info=True)
                print_warning(f"Skipped {function_name}: {str(exc)[:100]}")

        print_success(f"Analysed {len(results)} Lambda functions.")
        return results

    # ── DataFrame integration ─────────────────────────────────────────────────

    def enrich_dataframe(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Add LM1-LM5 signal columns to a DataFrame containing function_name.

        Returns the input DataFrame with additional columns:
            lambda_signals, lambda_recommendation, lambda_invocations_90d,
            lambda_error_rate_pct, lambda_runtime
        """
        if "function_name" not in df.columns:
            print_warning("DataFrame missing 'function_name' column — skipping lambda enrichment.")
            df["lambda_signals"] = ""
            df["lambda_recommendation"] = LambdaRecommendation.KEEP.value
            df["lambda_invocations_90d"] = 0.0
            df["lambda_error_rate_pct"] = 0.0
            df["lambda_runtime"] = "unknown"
            return df

        names = df["function_name"].dropna().tolist()
        analyses = self.analyze(resource_ids=names if names else None)
        analysis_map = {a.function_name: a for a in analyses}

        def _get(name: str, attr: str, default: Any) -> Any:
            a = analysis_map.get(name)
            return getattr(a, attr, default) if a else default

        df = df.copy()
        df["lambda_signals"] = df["function_name"].map(lambda n: ", ".join(s.value for s in _get(n, "signals", [])))
        df["lambda_recommendation"] = df["function_name"].map(
            lambda n: _get(n, "recommendation", LambdaRecommendation.KEEP).value
        )
        df["lambda_invocations_90d"] = df["function_name"].map(
            lambda n: _get(n, "metrics", LambdaMetrics()).total_invocations_90d
        )
        df["lambda_error_rate_pct"] = df["function_name"].map(
            lambda n: round(_get(n, "metrics", LambdaMetrics()).error_rate * 100, 1)
        )
        df["lambda_runtime"] = df["function_name"].map(lambda n: _get(n, "runtime", "unknown"))
        return df

    # ── Display ───────────────────────────────────────────────────────────────

    def display_results(self, results: List[LambdaAnalysis]) -> None:
        """Render a Rich table summarising Lambda decommission candidates."""
        if not results:
            print_warning("No Lambda results to display.")
            return

        from rich.table import Table as RichTable

        table = RichTable(
            title="Lambda Function Decommission Analysis",
            show_header=True,
            header_style="bold",
            box=__import__("rich.box", fromlist=["ROUNDED"]).ROUNDED,
        )
        table.add_column("Function Name", style="cyan", max_width=40, no_wrap=True)
        table.add_column("Runtime", style="white", min_width=10)
        table.add_column("Invoc (90d)", style="bright_yellow", justify="right", min_width=10)
        table.add_column("Err %", style="bright_yellow", justify="right", min_width=6)
        table.add_column("P99 ms", style="bright_yellow", justify="right", min_width=8)
        table.add_column("Signals", style="bright_magenta", min_width=12)
        table.add_column("Action", style="bold", min_width=12)

        for a in sorted(results, key=lambda x: x.recommendation.value):
            signals_str = ", ".join(s.value for s in a.signals) or "—"
            rec = a.recommendation
            if rec == LambdaRecommendation.DECOMMISSION:
                rec_str = f"[bright_red]{rec.value}[/bright_red]"
            elif rec == LambdaRecommendation.INVESTIGATE:
                rec_str = f"[bright_yellow]{rec.value}[/bright_yellow]"
            else:
                rec_str = f"[bright_green]{rec.value}[/bright_green]"

            runtime_str = a.runtime
            if a.runtime in EOL_RUNTIMES:
                runtime_str = f"[bright_red]{a.runtime}[/bright_red]"

            table.add_row(
                a.function_name,
                runtime_str,
                f"{a.metrics.total_invocations_90d:,.0f}",
                f"{a.metrics.error_rate * 100:.1f}%",
                f"{a.metrics.duration_p99_ms:,.0f} ms",
                signals_str,
                rec_str,
            )

        console.print()
        console.print(table)

        # Summary line
        decom_count = sum(1 for a in results if a.recommendation == LambdaRecommendation.DECOMMISSION)
        inv_count = sum(1 for a in results if a.recommendation == LambdaRecommendation.INVESTIGATE)
        console.print(
            f"\n[dim]Total: {len(results)} functions | "
            f"DECOMMISSION candidates: {decom_count} | INVESTIGATE: {inv_count}[/dim]\n"
        )


def create_lambda_enricher(
    profile: Optional[str] = None,
    region: Optional[str] = None,
) -> LambdaEnricher:
    """Factory function for LambdaEnricher."""
    return LambdaEnricher(profile=profile, region=region)
