"""
reporting.py — Text reports, data export, and matplotlib visualisations for
Open FAIR simulation results.

All plot functions return a :class:`matplotlib.figure.Figure` object so
callers can further customise or embed it in notebooks/dashboards.
"""

from __future__ import annotations

import csv
import json
import os
from typing import Dict, List, Optional, Tuple, Union

import numpy as np

from .analysis import classify_risk_level, compute_metrics, rank_scenarios
from .simulation import AggregateSimulationResult, SimulationResult


# ---------------------------------------------------------------------------
# Formatting helpers
# ---------------------------------------------------------------------------

def format_currency(value: float, symbol: str = "$") -> str:
    """Format a dollar value as a compact human-readable string.

    Examples
    --------
    >>> format_currency(1_500_000)
    '$1.50M'
    >>> format_currency(85_000)
    '$85.0K'
    >>> format_currency(4_200)
    '$4,200'
    """
    if abs(value) >= 1_000_000:
        return f"{symbol}{value / 1_000_000:.2f}M"
    if abs(value) >= 1_000:
        return f"{symbol}{value / 1_000:.1f}K"
    return f"{symbol}{value:,.0f}"


def format_frequency(freq: float) -> str:
    """Format an annual frequency as a human-readable string.

    Examples
    --------
    >>> format_frequency(4.0)
    '4.0x/year'
    >>> format_frequency(0.2)
    'Once per 5.0 years'
    >>> format_frequency(0.0)
    '0.0x/year'
    """
    if freq <= 0:
        return f"{freq:.1f}x/year"
    if freq >= 1.0:
        return f"{freq:.1f}x/year"
    return f"Once per {1.0 / freq:.1f} years"


# ---------------------------------------------------------------------------
# Text reports
# ---------------------------------------------------------------------------

def scenario_report(result: SimulationResult, verbose: bool = True) -> str:
    """Generate a detailed text report for a single scenario simulation result.

    Parameters
    ----------
    result:  The simulation result to report on.
    verbose: If True (default), include loss category breakdown and
             distribution percentiles.

    Returns
    -------
    Multi-line string suitable for printing to a terminal or log file.
    """
    metrics = compute_metrics(result)
    sep = "=" * 70
    thin = "-" * 70

    lines = [
        sep,
        f"  OPEN FAIR SCENARIO REPORT: {result.scenario_name}",
        sep,
        f"  Simulations  : {result.n_simulations:,}",
        f"  Risk Level   : {metrics.risk_level}",
        "",
        "  ANNUAL LOSS EXPOSURE (ALE)",
        thin,
        f"  Mean         : {format_currency(metrics.mean_ale)}",
        f"  Std Dev      : {format_currency(metrics.std_ale)}",
        f"  CV           : {metrics.cv:.2%}",
        f"  Min          : {format_currency(metrics.min_ale)}",
        f"  P10          : {format_currency(metrics.p10_ale)}",
        f"  Median (P50) : {format_currency(metrics.median_ale)}",
        f"  P90          : {format_currency(metrics.p90_ale)}",
        f"  P95          : {format_currency(metrics.p95_ale)}",
        f"  P99          : {format_currency(metrics.p99_ale)}",
        f"  Max          : {format_currency(metrics.max_ale)}",
        "",
        "  RISK MEASURES",
        thin,
        f"  VaR (95%)    : {format_currency(metrics.var_95)}",
        f"  CVaR (95%)   : {format_currency(metrics.cvar_95)}",
    ]

    if verbose:
        lines += [
            "",
            "  FREQUENCY & MAGNITUDE",
            thin,
            f"  Mean LEF     : {format_frequency(metrics.mean_lef)}",
            f"  Median LEF   : {format_frequency(metrics.p50_lef)}",
            f"  Mean LM      : {format_currency(metrics.mean_lm)}",
            f"  Median LM    : {format_currency(metrics.p50_lm)}",
        ]

        if result.loss_by_category:
            lines += [
                "",
                "  PRIMARY LOSS BREAKDOWN (mean per simulation)",
                thin,
            ]
            total_cat = sum(
                float(np.mean(v)) for v in result.loss_by_category.values()
            )
            for cat, arr in result.loss_by_category.items():
                cat_mean = float(np.mean(arr))
                pct = (cat_mean / total_cat * 100) if total_cat > 0 else 0.0
                label = cat.replace("_", " ").title()
                lines.append(
                    f"  {label:<30} : {format_currency(cat_mean):>12}  ({pct:.1f}%)"
                )

        if result.secondary_loss is not None:
            sec_mean = float(np.mean(result.secondary_loss))
            lines += [
                "",
                f"  Mean Secondary Loss      : {format_currency(sec_mean)}",
            ]

    lines.append(sep)
    return "\n".join(lines)


def register_report(agg_result: AggregateSimulationResult) -> str:
    """Generate an aggregate text report for a risk register simulation.

    Parameters
    ----------
    agg_result: The aggregate simulation result for a risk register.

    Returns
    -------
    Multi-line string with aggregate portfolio statistics and scenario rankings.
    """
    sep = "=" * 70
    thin = "-" * 70
    agg = agg_result.aggregate_ale

    total_mean = float(np.mean(agg))

    lines = [
        sep,
        f"  OPEN FAIR RISK REGISTER REPORT: {agg_result.register_name}",
        sep,
        f"  Scenarios    : {len(agg_result.scenario_results)}",
        f"  Simulations  : {agg_result.n_simulations:,}",
        "",
        "  AGGREGATE PORTFOLIO — ANNUAL LOSS EXPOSURE",
        thin,
        f"  Mean ALE     : {format_currency(total_mean)}",
        f"  Median ALE   : {format_currency(float(np.percentile(agg, 50)))}",
        f"  P90 ALE      : {format_currency(float(np.percentile(agg, 90)))}",
        f"  P95 ALE      : {format_currency(float(np.percentile(agg, 95)))}",
        f"  P99 ALE      : {format_currency(float(np.percentile(agg, 99)))}",
        f"  Max ALE      : {format_currency(float(np.max(agg)))}",
        f"  VaR (95%)    : {format_currency(agg_result.var(0.95))}",
        f"  CVaR (95%)   : {format_currency(agg_result.cvar(0.95))}",
        "",
        "  SCENARIO RANKINGS (by Mean ALE, descending)",
        thin,
        f"  {'#':<4} {'Scenario':<35} {'Mean ALE':>12} {'% of Total':>10} {'Level':<10}",
        thin,
    ]

    ranked = rank_scenarios(agg_result.scenario_results, by="mean_ale")
    for i, (name, mean_ale) in enumerate(ranked, start=1):
        pct = (mean_ale / total_mean * 100.0) if total_mean > 0 else 0.0
        level = classify_risk_level(mean_ale)
        # Truncate long names
        display_name = name[:33] + ".." if len(name) > 35 else name
        lines.append(
            f"  {i:<4} {display_name:<35} {format_currency(mean_ale):>12} {pct:>9.1f}% {level:<10}"
        )

    lines.append(sep)
    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Data export
# ---------------------------------------------------------------------------

def export_json(result: SimulationResult, path: str) -> None:
    """Export a simulation result's summary statistics to a JSON file.

    Parameters
    ----------
    result: The simulation result to export.
    path:   File path (will be created or overwritten).
    """
    os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
    summary = result.summary()
    with open(path, "w", encoding="utf-8") as f:
        json.dump(summary, f, indent=2, default=float)


def export_csv(result: SimulationResult, path: str) -> None:
    """Export per-iteration simulation data to a CSV file.

    Columns: iteration, lef, tef, vulnerability, primary_loss,
             secondary_loss, lm, ale, plus any loss-by-category columns.

    Parameters
    ----------
    result: The simulation result to export.
    path:   File path (will be created or overwritten).
    """
    os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
    n = result.n_simulations

    # Build column arrays
    rows: Dict[str, np.ndarray] = {
        "iteration": np.arange(1, n + 1),
        "lef": result.loss_event_frequency,
    }
    if result.threat_event_frequency is not None:
        rows["tef"] = result.threat_event_frequency
    if result.vulnerability is not None:
        rows["vulnerability"] = result.vulnerability
    if result.primary_loss is not None:
        rows["primary_loss"] = result.primary_loss
    if result.secondary_loss is not None:
        rows["secondary_loss"] = result.secondary_loss
    for cat, arr in result.loss_by_category.items():
        rows[f"loss_{cat}"] = arr
    rows["lm"] = result.loss_magnitude
    rows["ale"] = result.annual_loss_exposure

    fieldnames = list(rows.keys())
    with open(path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        for i in range(n):
            writer.writerow({col: float(arr[i]) for col, arr in rows.items()})


# ---------------------------------------------------------------------------
# Matplotlib visualisations
# ---------------------------------------------------------------------------

def _risk_level_color(level: str) -> str:
    """Map a risk level string to a hex colour."""
    return {
        "LOW": "#2ecc71",      # green
        "MEDIUM": "#f39c12",   # orange
        "HIGH": "#e74c3c",     # red
        "CRITICAL": "#8e44ad", # purple
    }.get(level, "#95a5a6")


def plot_ale_distribution(
    result: SimulationResult,
    title: Optional[str] = None,
    show_percentiles: bool = True,
    figsize: Tuple[float, float] = (12, 5),
    save_path: Optional[str] = None,
) -> "matplotlib.figure.Figure":  # noqa: F821
    """Plot a dual-panel ALE distribution: histogram and empirical CDF.

    Parameters
    ----------
    result:          Simulation result to plot.
    title:           Optional figure title.  Defaults to scenario name.
    show_percentiles: If True, overlay P10/P50/P90/P95 lines.
    figsize:         Matplotlib figure size tuple.
    save_path:       If provided, save the figure to this path.

    Returns
    -------
    :class:`matplotlib.figure.Figure`
    """
    import matplotlib.pyplot as plt
    import matplotlib.ticker as mtick

    ale = result.annual_loss_exposure
    metrics = compute_metrics(result)
    percentile_vals = {
        "P10": metrics.p10_ale,
        "P50": metrics.median_ale,
        "P90": metrics.p90_ale,
        "P95": metrics.p95_ale,
    }
    colors = ["#3498db", "#2ecc71", "#f39c12", "#e74c3c"]

    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize)
    plot_title = title or f"ALE Distribution — {result.scenario_name}"
    fig.suptitle(plot_title, fontsize=13, fontweight="bold")

    # --- Panel 1: Histogram ---
    ax1.hist(ale, bins=100, color="#3498db", alpha=0.7, edgecolor="none")
    if show_percentiles:
        for (label, val), color in zip(percentile_vals.items(), colors):
            ax1.axvline(val, color=color, linewidth=1.5, linestyle="--", label=f"{label}: {format_currency(val)}")
        ax1.legend(fontsize=8)
    ax1.set_xlabel("Annual Loss Exposure ($)", fontsize=10)
    ax1.set_ylabel("Frequency", fontsize=10)
    ax1.set_title("Histogram", fontsize=10)
    ax1.xaxis.set_major_formatter(
        mtick.FuncFormatter(lambda x, _: format_currency(x))
    )
    ax1.tick_params(axis="x", labelsize=8, rotation=30)

    # --- Panel 2: CDF ---
    sorted_ale = np.sort(ale)
    cdf = np.arange(1, len(sorted_ale) + 1) / len(sorted_ale)
    ax2.plot(sorted_ale, cdf, color="#2c3e50", linewidth=1.5)
    if show_percentiles:
        for (label, val), color in zip(percentile_vals.items(), colors):
            p = percentile_vals
            quantile = list(percentile_vals.keys()).index(label)
            target_pct = [0.10, 0.50, 0.90, 0.95][quantile]
            ax2.axvline(val, color=color, linewidth=1.2, linestyle="--")
            ax2.axhline(target_pct, color=color, linewidth=0.8, linestyle=":")
    ax2.set_xlabel("Annual Loss Exposure ($)", fontsize=10)
    ax2.set_ylabel("Cumulative Probability", fontsize=10)
    ax2.set_title("Empirical CDF", fontsize=10)
    ax2.set_ylim(0, 1)
    ax2.xaxis.set_major_formatter(
        mtick.FuncFormatter(lambda x, _: format_currency(x))
    )
    ax2.tick_params(axis="x", labelsize=8, rotation=30)
    ax2.yaxis.set_major_formatter(mtick.PercentFormatter(xmax=1))

    plt.tight_layout()

    if save_path:
        os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True)
        fig.savefig(save_path, dpi=150, bbox_inches="tight")

    return fig


def plot_scenario_comparison(
    results: List[SimulationResult],
    metric: str = "mean_ale",
    title: Optional[str] = None,
    figsize: Tuple[float, float] = (10, 6),
    save_path: Optional[str] = None,
) -> "matplotlib.figure.Figure":  # noqa: F821
    """Horizontal bar chart comparing scenarios by a chosen ALE metric.

    Bars include IQR (P25-P75) error whiskers and are colour-coded by
    risk level.

    Parameters
    ----------
    results: List of simulation results to compare.
    metric:  Bar height metric — one of ``'mean_ale'``, ``'median_ale'``,
             ``'p95_ale'``, ``'var_95'``, ``'cvar_95'``.
    title:   Optional figure title.
    figsize: Matplotlib figure size tuple.
    save_path: If provided, save the figure to this path.

    Returns
    -------
    :class:`matplotlib.figure.Figure`
    """
    import matplotlib.pyplot as plt
    import matplotlib.ticker as mtick

    _metric_label = {
        "mean_ale": "Mean ALE",
        "median_ale": "Median ALE",
        "p95_ale": "P95 ALE",
        "var_95": "VaR (95%)",
        "cvar_95": "CVaR (95%)",
    }

    def _get_metric(r: SimulationResult) -> float:
        if metric == "mean_ale":
            return r.mean_ale
        if metric == "median_ale":
            return r.percentile(50)
        if metric == "p95_ale":
            return r.percentile(95)
        if metric == "var_95":
            return r.var(0.95)
        if metric == "cvar_95":
            return r.cvar(0.95)
        raise ValueError(f"Unknown metric '{metric}'")

    # Sort descending
    sorted_results = sorted(results, key=_get_metric, reverse=True)
    names = [r.scenario_name for r in sorted_results]
    values = [_get_metric(r) for r in sorted_results]
    p25 = [r.percentile(25) for r in sorted_results]
    p75 = [r.percentile(75) for r in sorted_results]
    colors = [_risk_level_color(classify_risk_level(v)) for v in values]

    fig, ax = plt.subplots(figsize=figsize)
    y_pos = np.arange(len(names))

    # Horizontal bars
    bars = ax.barh(y_pos, values, color=colors, alpha=0.85, edgecolor="white")

    # IQR whiskers
    for i, (v, lo, hi) in enumerate(zip(values, p25, p75)):
        ax.errorbar(
            v, i, xerr=[[v - lo], [hi - v]],
            fmt="none", color="black", capsize=4, linewidth=1.5
        )

    ax.set_yticks(y_pos)
    ax.set_yticklabels(
        [n[:40] + ".." if len(n) > 42 else n for n in names], fontsize=9
    )
    ax.invert_yaxis()
    ax.xaxis.set_major_formatter(
        mtick.FuncFormatter(lambda x, _: format_currency(x))
    )
    ax.tick_params(axis="x", labelsize=8, rotation=30)

    plot_title = title or f"Scenario Comparison — {_metric_label.get(metric, metric)}"
    ax.set_title(plot_title, fontsize=12, fontweight="bold")
    ax.set_xlabel(_metric_label.get(metric, metric), fontsize=10)

    # Legend for risk levels
    from matplotlib.patches import Patch
    legend_elements = [
        Patch(facecolor=_risk_level_color(lvl), label=lvl)
        for lvl in ["LOW", "MEDIUM", "HIGH", "CRITICAL"]
    ]
    ax.legend(handles=legend_elements, loc="lower right", fontsize=8)

    plt.tight_layout()

    if save_path:
        os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True)
        fig.savefig(save_path, dpi=150, bbox_inches="tight")

    return fig


def plot_risk_matrix(
    results: List[SimulationResult],
    title: Optional[str] = None,
    figsize: Tuple[float, float] = (10, 7),
    save_path: Optional[str] = None,
) -> "matplotlib.figure.Figure":  # noqa: F821
    """Scatter plot on Frequency vs. Magnitude axes with coloured risk zones.

    Each scenario is plotted as a bubble:
    - X axis: Mean Loss Event Frequency (log scale)
    - Y axis: Mean Loss Magnitude per event (log scale)
    - Bubble size: proportional to sqrt(Mean ALE)
    - Bubble colour: risk level

    Risk zones (diagonal bands) are colour-shaded to indicate LOW / MEDIUM /
    HIGH / CRITICAL risk regions.

    Parameters
    ----------
    results:   List of simulation results to plot.
    title:     Optional figure title.
    figsize:   Matplotlib figure size tuple.
    save_path: If provided, save the figure to this path.

    Returns
    -------
    :class:`matplotlib.figure.Figure`
    """
    import matplotlib.pyplot as plt
    import matplotlib.ticker as mtick
    from matplotlib.patches import Patch

    fig, ax = plt.subplots(figsize=figsize)

    # Draw coloured risk-zone background bands based on ALE = LEF × LM
    # contour lines at $10K, $100K, $1M, $10M
    lef_range = np.logspace(-2, 3, 200)
    ale_thresholds = [10_000, 100_000, 1_000_000, 10_000_000]
    zone_colors = ["#d5f5e3", "#fef9e7", "#fadbd8", "#e8daef"]
    zone_labels = ["LOW", "MEDIUM", "HIGH", "CRITICAL"]

    # Fill zones between ALE contour lines
    for i in range(len(ale_thresholds)):
        if i == 0:
            lm_upper = ale_thresholds[0] / lef_range
            ax.fill_between(
                lef_range, 1, lm_upper,
                color=zone_colors[0], alpha=0.4, zorder=0
            )
        else:
            lm_lower = ale_thresholds[i - 1] / lef_range
            lm_upper = ale_thresholds[i] / lef_range
            ax.fill_between(
                lef_range, lm_lower, lm_upper,
                color=zone_colors[i], alpha=0.4, zorder=0
            )

    lm_above = ale_thresholds[-1] / lef_range
    ax.fill_between(
        lef_range, lm_above, 1e10,
        color=zone_colors[-1], alpha=0.4, zorder=0
    )

    # Plot each scenario as a bubble
    for r in results:
        mean_lef = float(np.mean(r.loss_event_frequency))
        mean_lm = float(np.mean(r.loss_magnitude))
        mean_ale = r.mean_ale
        level = classify_risk_level(mean_ale)
        color = _risk_level_color(level)
        size = max(50, min(2000, 20 * np.sqrt(mean_ale / 1000)))

        ax.scatter(
            mean_lef, mean_lm, s=size, c=color,
            alpha=0.85, edgecolors="white", linewidths=1.2, zorder=3
        )
        ax.annotate(
            r.scenario_name[:25],
            (mean_lef, mean_lm),
            textcoords="offset points", xytext=(6, 6),
            fontsize=7.5, zorder=4
        )

    ax.set_xscale("log")
    ax.set_yscale("log")
    ax.set_xlabel("Loss Event Frequency (events/year)", fontsize=10)
    ax.set_ylabel("Loss Magnitude per Event ($)", fontsize=10)
    ax.set_xlim(max(1e-3, min(float(np.mean(r.loss_event_frequency)) for r in results) / 10),
                max(float(np.mean(r.loss_event_frequency)) for r in results) * 10)
    ax.set_ylim(max(1, min(float(np.mean(r.loss_magnitude)) for r in results) / 10),
                max(float(np.mean(r.loss_magnitude)) for r in results) * 10)
    ax.yaxis.set_major_formatter(
        mtick.FuncFormatter(lambda x, _: format_currency(x))
    )
    ax.tick_params(labelsize=8)

    legend_elements = [
        Patch(facecolor=_risk_level_color(lvl), alpha=0.7, label=lvl)
        for lvl in zone_labels
    ]
    ax.legend(handles=legend_elements, title="Risk Level", fontsize=8, title_fontsize=8)

    plot_title = title or "Risk Matrix — Frequency vs. Magnitude"
    ax.set_title(plot_title, fontsize=12, fontweight="bold")
    ax.grid(True, which="both", alpha=0.3, zorder=1)

    plt.tight_layout()

    if save_path:
        os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True)
        fig.savefig(save_path, dpi=150, bbox_inches="tight")

    return fig
