"""
utils.py — Calibration helpers, validation, unit conversions, and table
formatting utilities for the Open FAIR library.
"""

from __future__ import annotations

import math
import warnings
from typing import List, Tuple

import numpy as np
from scipy import stats

from .simulation import SimulationResult


# ---------------------------------------------------------------------------
# Scenario validation
# ---------------------------------------------------------------------------

def validate_scenario(scenario: "FAIRScenario") -> List[str]:  # noqa: F821
    """Validate a :class:`~pycrq.ontology.FAIRScenario` and return warnings.

    This function performs heuristic checks and returns a list of advisory
    strings.  An empty list means no issues were detected.  These are
    *warnings*, not hard errors — the scenario can still be simulated.

    Parameters
    ----------
    scenario: The scenario to validate.

    Returns
    -------
    List of human-readable warning strings.
    """
    from .ontology import FrequencyFactors, LossMagnitudeFactors

    warnings_list: List[str] = []
    ff: FrequencyFactors = scenario.frequency_factors
    lmf: LossMagnitudeFactors = scenario.loss_magnitude_factors

    # --- Frequency checks ---
    if ff.tef is None and ff.contact_frequency is None:
        warnings_list.append(
            "No frequency input detected (tef or contact_frequency is required)."
        )

    if ff.vulnerability is None and ff.threat_capability is None and ff.control_strength is None:
        warnings_list.append(
            "No vulnerability input detected; the simulator will default to Uniform(0,1)."
        )

    if ff.vulnerability is not None:
        v_mean = ff.vulnerability.mean()
        if v_mean > 0.9:
            warnings_list.append(
                f"Vulnerability mean ({v_mean:.2f}) is very high (> 0.90). "
                "Verify this reflects your threat environment."
            )
        if v_mean < 0.02:
            warnings_list.append(
                f"Vulnerability mean ({v_mean:.4f}) is very low (< 0.02). "
                "Consider whether your control assumptions are realistic."
            )

    # --- Loss magnitude checks ---
    has_loss = (
        lmf.primary_loss is not None
        or any(
            getattr(lmf, cat) is not None
            for cat in ("productivity", "response", "replacement",
                        "competitive_advantage", "fines_judgments", "reputation")
        )
    )
    if not has_loss:
        warnings_list.append(
            "No primary loss input detected (primary_loss or loss categories required)."
        )

    if lmf.secondary_loss_event_frequency is not None and lmf.secondary_loss_magnitude is None:
        warnings_list.append(
            "secondary_loss_event_frequency is set but secondary_loss_magnitude is missing. "
            "Secondary loss will not be computed."
        )

    if lmf.secondary_loss_magnitude is not None and lmf.secondary_loss_event_frequency is None:
        warnings_list.append(
            "secondary_loss_magnitude is set but secondary_loss_event_frequency is missing. "
            "Secondary loss will not be computed."
        )

    if lmf.secondary_loss_event_frequency is None and lmf.secondary_loss_magnitude is None:
        warnings_list.append(
            "Secondary loss factors are not defined. "
            "Consider whether regulatory fines, third-party costs, or "
            "reputational damage should be modelled as secondary losses."
        )

    # --- Time horizon checks ---
    if scenario.time_horizon_years > 5:
        warnings_list.append(
            f"Time horizon is {scenario.time_horizon_years} years. "
            "Long horizons increase ALE but reduce model reliability; "
            "consider analysing on a 1-year basis."
        )

    # --- Controls ---
    if not scenario.controls:
        warnings_list.append(
            "No controls are associated with this scenario. "
            "Consider documenting existing mitigating controls."
        )

    return warnings_list


# ---------------------------------------------------------------------------
# PERT calibration from confidence bounds
# ---------------------------------------------------------------------------

def calibrate_pert_from_confidence(
    p10: float, p90: float
) -> Tuple[float, float, float]:
    """Estimate PERT (low, mode, high) parameters from P10/P90 expert elicitation.

    Uses the standard SME calibration heuristic:
        low  ≈ p10 × 0.75   (extend 25% below the 10th percentile)
        high ≈ p90 × 1.25   (extend 25% above the 90th percentile)
        mode ≈ median of a Beta fitted to (p10, p90)

    A more principled approach solves for the Beta parameters that satisfy
    the percentile constraints; this implementation uses moment-matching as
    a fast approximation.

    Parameters
    ----------
    p10: Expert's 10th-percentile estimate of the quantity.
    p90: Expert's 90th-percentile estimate of the quantity.

    Returns
    -------
    ``(low, mode, high)`` suitable for :class:`~pycrq.distributions.PERTDistribution`.
    """
    if p10 >= p90:
        raise ValueError(
            f"p10 must be strictly less than p90; got p10={p10}, p90={p90}"
        )
    if p10 < 0 or p90 < 0:
        raise ValueError("p10 and p90 must be non-negative.")

    # Simple robust heuristic used by many calibration practitioners:
    #   - extend tails by 25% beyond the confidence bounds
    #   - mode ≈ weighted average biased toward the geometric midpoint
    low = p10 * 0.75
    high = p90 * 1.25

    # Geometric mean of p10 and p90 as mode estimate (common for skewed risks)
    mode_geo = math.sqrt(p10 * p90) if p10 > 0 else p90 * 0.3

    # Clamp mode within [low, high]
    mode = min(max(mode_geo, low), high)

    return low, mode, high


# ---------------------------------------------------------------------------
# Frequency conversion helpers
# ---------------------------------------------------------------------------

_FREQUENCY_MAP = {
    # Annual
    "annual": 1.0,
    "annually": 1.0,
    "once a year": 1.0,
    "once per year": 1.0,
    "yearly": 1.0,
    # Quarterly
    "quarterly": 4.0,
    "once a quarter": 4.0,
    "once per quarter": 4.0,
    # Monthly
    "monthly": 12.0,
    "once a month": 12.0,
    "once per month": 12.0,
    # Biweekly
    "biweekly": 26.0,
    "bi-weekly": 26.0,
    "every two weeks": 26.0,
    # Weekly
    "weekly": 52.0,
    "once a week": 52.0,
    "once per week": 52.0,
    # Daily
    "daily": 365.0,
    "every day": 365.0,
    "once a day": 365.0,
    # Hourly
    "hourly": 8_760.0,
}


def frequency_to_rate(description: str) -> float:
    """Convert a natural-language frequency description to an annual rate.

    Parameters
    ----------
    description: One of: ``'daily'``, ``'weekly'``, ``'monthly'``,
                 ``'quarterly'``, ``'annual'``/``'yearly'``, etc.

    Returns
    -------
    Annual frequency as a float.

    Raises
    ------
    ValueError: If the description is not recognised.
    """
    key = description.strip().lower()
    if key in _FREQUENCY_MAP:
        return _FREQUENCY_MAP[key]

    # Try to parse "N times per year" / "N per year"
    import re
    m = re.match(r"(\d+(?:\.\d+)?)\s*(?:times?\s*)?per\s*year", key)
    if m:
        return float(m.group(1))

    # "once per N years"
    m = re.match(r"once\s*(?:every|per)\s*(\d+(?:\.\d+)?)\s*year", key)
    if m:
        return 1.0 / float(m.group(1))

    raise ValueError(
        f"Unrecognised frequency description: '{description}'. "
        f"Known values: {sorted(_FREQUENCY_MAP.keys())}"
    )


# ---------------------------------------------------------------------------
# Annualisation
# ---------------------------------------------------------------------------

_PERIOD_FACTORS = {
    "hourly": 8_760.0,
    "daily": 365.0,
    "weekly": 52.0,
    "biweekly": 26.0,
    "monthly": 12.0,
    "quarterly": 4.0,
    "semi-annual": 2.0,
    "semiannual": 2.0,
    "annual": 1.0,
    "annually": 1.0,
    "yearly": 1.0,
}


def annualize(value: float, period: str = "monthly") -> float:
    """Convert a periodic value (e.g. monthly cost) to an annual figure.

    Parameters
    ----------
    value:  Periodic value to convert.
    period: Frequency of the value — one of ``'hourly'``, ``'daily'``,
            ``'weekly'``, ``'monthly'``, ``'quarterly'``, etc.

    Returns
    -------
    Annual equivalent as a float.
    """
    key = period.strip().lower()
    if key not in _PERIOD_FACTORS:
        raise ValueError(
            f"Unrecognised period '{period}'. "
            f"Choose from: {sorted(_PERIOD_FACTORS.keys())}"
        )
    return value * _PERIOD_FACTORS[key]


# ---------------------------------------------------------------------------
# ASCII summary table
# ---------------------------------------------------------------------------

def summary_table(results: List[SimulationResult]) -> str:
    """Produce a formatted ASCII table comparing multiple simulation results.

    Parameters
    ----------
    results: List of :class:`~pycrq.simulation.SimulationResult` objects.

    Returns
    -------
    Multi-line string suitable for printing to a terminal.
    """
    from .analysis import classify_risk_level, compute_metrics
    from .reporting import format_currency

    if not results:
        return "(no results to display)"

    metrics_list = [compute_metrics(r) for r in results]

    # Column widths
    name_width = max(len("Scenario"), max(min(len(m.scenario_name), 35) for m in metrics_list))
    col_w = 13

    header_cols = [
        "Scenario", "Mean ALE", "Median ALE", "P90 ALE", "P95 ALE",
        "VaR(95%)", "CVaR(95%)", "Std Dev", "Level"
    ]

    def row_fmt(name: str, *vals: str) -> str:
        truncated = name[:name_width]
        cells = [f"{truncated:<{name_width}}"]
        cells += [f"{v:>{col_w}}" for v in vals[:-1]]
        cells += [f"  {vals[-1]:<10}"]
        return "  ".join(cells)

    sep = "-" * (name_width + (col_w + 2) * 7 + 12)
    lines = [
        sep,
        row_fmt(
            "Scenario", "Mean ALE", "Median ALE", "P90 ALE", "P95 ALE",
            "VaR(95%)", "CVaR(95%)", "Std Dev", "Level"
        ),
        sep,
    ]

    for m in metrics_list:
        lines.append(
            row_fmt(
                m.scenario_name,
                format_currency(m.mean_ale),
                format_currency(m.median_ale),
                format_currency(m.p90_ale),
                format_currency(m.p95_ale),
                format_currency(m.var_95),
                format_currency(m.cvar_95),
                format_currency(m.std_ale),
                m.risk_level,
            )
        )

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