"""One-at-a-time sweeps and tornado data.

Data only — the notebook draws the figures. Drivers are addressed by a
short dotted path so the notebook can name them without reaching into the
dataclasses itself.
"""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import replace
from typing import Any

from .model import CalculatorInputs, CalculatorResult, calculate

#: Driver name → (container attribute on CalculatorInputs, field on it).
#: ``None`` container means the field sits directly on CalculatorInputs.
DRIVERS: dict[str, tuple[str | None, str]] = {
    "token_price": ("price", "list_rate"),
    "concession_pct": ("price", "concession_pct"),
    "users": (None, "users"),
    "voice_volume": ("volumes", "voice_inbound_monthly"),
    "bot_share": ("mix", "bot_only_share"),
    "virtual_agent_share": ("mix", "virtual_agent_share"),
    "agentic_va_share": ("mix", "agentic_va_share"),
    "bot_seconds": ("voice_bot", "avg_bot_seconds_per_call"),
    "tts_chars_per_call": ("tts", "chars_per_call"),
}


def driver_value(inputs: CalculatorInputs, driver: str) -> float:
    """The current value of ``driver`` in ``inputs``."""
    container, attr = _resolve(driver)
    target = inputs if container is None else getattr(inputs, container)
    if target is None:
        raise ValueError(f"{driver} is not active in this scenario")
    return float(getattr(target, attr))


def set_driver(inputs: CalculatorInputs, driver: str, value: float) -> CalculatorInputs:
    """A copy of ``inputs`` with ``driver`` set to ``value``."""
    container, attr = _resolve(driver)
    if container is None:
        return inputs.with_(**{attr: _coerce(attr, value)})
    current = getattr(inputs, container)
    if current is None:
        raise ValueError(f"{driver} is not active in this scenario")
    return inputs.with_(**{container: replace(current, **{attr: _coerce(attr, value)})})


def _resolve(driver: str) -> tuple[str | None, str]:
    try:
        return DRIVERS[driver]
    except KeyError:
        raise KeyError(
            f"{driver!r} is not a known driver. Known: {', '.join(sorted(DRIVERS))}"
        ) from None


def _coerce(attr: str, value: float) -> Any:
    """``users`` and ``chars_per_call`` are integers by contract."""
    if attr in ("users", "chars_per_call"):
        return int(round(value))
    return float(value)


def sweep(
    base: CalculatorInputs, driver: str, values: Sequence[float]
) -> list[dict[str, float]]:
    """Grand total across a range of one driver, everything else held."""
    rows: list[dict[str, float]] = []
    for value in values:
        result: CalculatorResult = calculate(set_driver(base, driver, value))
        rows.append(
            {
                driver: float(value),
                "token_cost_annual": result.totals.token_cost_annual,
                "tts_cost_annual": result.tts_cost_annual,
                "grand_total_annual": result.grand_total_annual,
            }
        )
    return rows


def tornado(
    base: CalculatorInputs, drivers: Sequence[str], delta: float = 0.25
) -> list[dict[str, Any]]:
    """±``delta`` on each driver in turn, sorted by the swing it causes.

    Drivers that are not active in the scenario (no TTS, no voice bot) are
    skipped rather than raising — a tornado over an inactive lever is noise.
    Shares are clamped to keep the highest-tier partition legal; a driver
    whose low and high both clamp to the same value contributes no swing.
    """
    if not 0.0 < delta < 1.0:
        raise ValueError("delta must be in (0, 1)")

    baseline = calculate(base).grand_total_annual
    rows: list[dict[str, Any]] = []

    for driver in drivers:
        try:
            current = driver_value(base, driver)
        except (ValueError, KeyError):
            continue

        low_value = current * (1.0 - delta)
        high_value = current * (1.0 + delta)
        if driver.endswith("_share"):
            low_value = max(0.0, min(1.0, low_value))
            high_value = max(0.0, min(1.0, high_value))

        try:
            low = calculate(set_driver(base, driver, low_value)).grand_total_annual
            high = calculate(set_driver(base, driver, high_value)).grand_total_annual
        except ValueError:
            # e.g. raising a share would break the partition — skip the lever
            continue

        rows.append(
            {
                "driver": driver,
                "baseline_value": current,
                "low_value": low_value,
                "high_value": high_value,
                "low": low,
                "high": high,
                "baseline": baseline,
                "swing": abs(high - low),
            }
        )

    return sorted(rows, key=lambda r: float(r["swing"]), reverse=True)
