"""Cost estimation for cloud resources — rates x usage, provider-agnostic.

The provider APIs (OpenStack et al.) expose *what* is running but not *what it
costs* — billing is provider-specific and rarely in the compute API. So this
module carries a small, **configurable** pricing model and derives an estimate
from the resources the provider already lists (servers + their flavors, volumes,
floating IPs) and each resource's age.

Defaults are Infomaniak Public Cloud's published pay-as-you-go rates (June 2026,
EUR) — the ``disk0`` flavors are pure compute, so the per-resource fallback is
fitted to them. **They are estimates: verify against your invoice and override
via** :class:`Rates` (e.g. from ``[tool.mgf-cloud.rates]`` or env) when they
drift. Sources:
https://www.infomaniak.com/en/hosting/public-cloud/prices and
https://pcr.cloud-mercato.com/providers/infomaniak .
"""

from __future__ import annotations

import os
import tomllib
from dataclasses import dataclass, field, replace
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING

from mgf.cloud.exceptions import ConfigError

if TYPE_CHECKING:
    from collections.abc import Mapping

    from mgf.cloud.models import Flavor, FloatingIP, Server, Volume

__all__ = [
    "HOURS_PER_MONTH",
    "CostLine",
    "CostReport",
    "Rates",
    "estimate",
    "load_rates",
    "rates_from_config",
]

#: Env var holding a path to a rates TOML; overrides the default search paths.
_RATES_ENV = "MGF_CLOUD_RATES"

#: Projection basis — average hours in a month (365.25 x 24 / 12).
HOURS_PER_MONTH = 730.5

#: Known per-flavor hourly prices (EUR), Infomaniak Public Cloud (June 2026).
#: A flavor present here is priced exactly; anything else falls back to the
#: per-resource rates below (so new/renamed flavors still get an estimate).
_INFOMANIAK_FLAVOR_HOURLY: dict[str, float] = {
    "a1-ram2-disk0": 0.0050,
    "a2-ram4-disk0": 0.0070,
    "a2-ram4-disk20-perf1": 0.0080,
    "a2-ram4-disk50-perf1": 0.0090,
    "a4-ram8-disk0": 0.0150,
    "a4-ram8-disk20-perf1": 0.0160,  # a4-ram8-disk0 + the 20 GB increment
    "a4-ram8-disk50-perf1": 0.0170,
    "a8-ram16-disk0": 0.0290,
    "a8-ram16-disk20-perf1": 0.0300,
}


@dataclass(frozen=True, slots=True)
class Rates:
    """A pricing model: per-flavor overrides + a per-resource fallback.

    All rates are **EUR per hour**. ``currency`` is display-only. Construct with
    no args for the Infomaniak defaults, or override any field to match your
    invoice / a different provider.
    """

    currency: str = "EUR"
    #: Exact €/h per flavor name; takes precedence over the per-resource fallback.
    flavor_hourly: dict[str, float] = field(default_factory=lambda: dict(_INFOMANIAK_FLAVOR_HOURLY))
    #: Per-resource fallback, fitted to the Infomaniak disk0 flavors.
    per_vcpu_hour: float = 0.00175
    per_gb_ram_hour: float = 0.000875
    per_gb_ephemeral_disk_hour: float = 0.00004
    #: Attached/standalone block-storage volumes, €/GB/h (≈ €0.03/GB-month).
    per_gb_volume_hour: float = 0.00004
    #: A reserved floating IP, €/h (Infomaniak public IPs are typically free).
    per_floating_ip_hour: float = 0.0

    def flavor_rate(self, flavor: Flavor | None, flavor_name: str) -> tuple[float, bool]:
        """Return ``(hourly_eur, exact)`` for a flavor.

        ``exact`` is True when the price came from :attr:`flavor_hourly`; False
        when it was estimated from the per-resource rates (flavor specs needed).
        """
        if flavor_name in self.flavor_hourly:
            return self.flavor_hourly[flavor_name], True
        if flavor is None:
            return 0.0, False
        est = (
            flavor.vcpus * self.per_vcpu_hour
            + (flavor.ram_mb / 1024) * self.per_gb_ram_hour
            + flavor.disk_gb * self.per_gb_ephemeral_disk_hour
        )
        return est, False


@dataclass(frozen=True, slots=True)
class CostLine:
    """One billable resource's contribution to the estimate."""

    kind: str  # "server" | "volume" | "floating-ip"
    name: str
    detail: str  # flavor name / "<n> GB" / IP address
    hourly: float
    exact: bool  # priced from the table (True) vs estimated from specs (False)
    age_hours: float | None  # None when the resource has no known creation time
    accrued: float  # hourly x age_hours (0.0 when age is unknown)


@dataclass(frozen=True, slots=True)
class CostReport:
    """A fleet-wide cost estimate at a point in time."""

    currency: str
    lines: tuple[CostLine, ...]
    generated_at: datetime

    @property
    def hourly(self) -> float:
        return sum(line.hourly for line in self.lines)

    @property
    def monthly(self) -> float:
        """Projected spend for a full month at the current run-rate."""
        return self.hourly * HOURS_PER_MONTH

    @property
    def daily(self) -> float:
        return self.hourly * 24

    @property
    def accrued(self) -> float:
        """Estimated cost already incurred (rate x age) across all resources."""
        return sum(line.accrued for line in self.lines)

    @property
    def any_estimated(self) -> bool:
        """True if any line used the per-resource fallback (not the price table)."""
        return any(not line.exact for line in self.lines if line.kind == "server")

    @property
    def any_age_unknown(self) -> bool:
        return any(line.age_hours is None for line in self.lines)


def _parse_age_hours(created_at: str | None, now: datetime) -> float | None:
    """Hours between ``created_at`` (ISO-8601, possibly ``Z``) and ``now``."""
    if not created_at:
        return None
    try:
        ts = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
    except ValueError:
        return None
    if ts.tzinfo is None:
        ts = ts.replace(tzinfo=UTC)
    delta = (now - ts).total_seconds() / 3600
    return max(delta, 0.0)


def estimate(
    *,
    servers: list[Server],
    flavors: list[Flavor],
    volumes: list[Volume] | None = None,
    floating_ips: list[FloatingIP] | None = None,
    rates: Rates | None = None,
    now: datetime | None = None,
) -> CostReport:
    """Build a :class:`CostReport` from the listed resources.

    Only ACTIVE-ish servers accrue compute cost; a stopped/SHUTOFF instance still
    bills for its boot volume on most providers, so it's kept (priced at its
    flavor rate is the conservative estimate). Pass ``volumes`` / ``floating_ips``
    to include block storage + reserved IPs.
    """
    rates = rates or Rates()
    now = now or datetime.now(UTC)
    by_name = {f.name: f for f in flavors}
    lines: list[CostLine] = []

    for s in servers:
        flavor = by_name.get(s.flavor)
        hourly, exact = rates.flavor_rate(flavor, s.flavor)
        age = _parse_age_hours(s.created_at, now)
        lines.append(
            CostLine(
                kind="server",
                name=s.name,
                detail=s.flavor or "?",
                hourly=hourly,
                exact=exact,
                age_hours=age,
                accrued=hourly * age if age is not None else 0.0,
            )
        )

    for v in volumes or ():
        hourly = v.size_gb * rates.per_gb_volume_hour
        lines.append(
            CostLine(
                kind="volume",
                name=v.name or v.id,
                detail=f"{v.size_gb} GB",
                hourly=hourly,
                exact=False,
                age_hours=None,
                accrued=0.0,
            )
        )

    for fip in floating_ips or ():
        hourly = rates.per_floating_ip_hour
        if hourly <= 0:
            continue
        lines.append(
            CostLine(
                kind="floating-ip",
                name=fip.address,
                detail="reserved IP",
                hourly=hourly,
                exact=True,
                age_hours=None,
                accrued=0.0,
            )
        )

    return CostReport(currency=rates.currency, lines=tuple(lines), generated_at=now)


def load_rates(overrides: dict[str, object] | None = None) -> Rates:
    """Build :class:`Rates`, applying a flat dict of overrides (e.g. from config).

    Recognised keys mirror the :class:`Rates` fields; ``flavor_hourly`` merges
    into (does not replace) the defaults so a partial table still benefits from
    the built-ins. Unknown keys are ignored.
    """
    rates = Rates()
    if not overrides:
        return rates
    table = dict(rates.flavor_hourly)
    extra = overrides.get("flavor_hourly")
    if isinstance(extra, dict):
        table.update({str(k): float(v) for k, v in extra.items()})
    scalar = {
        k: float(overrides[k])  # type: ignore[arg-type]
        for k in (
            "per_vcpu_hour",
            "per_gb_ram_hour",
            "per_gb_ephemeral_disk_hour",
            "per_gb_volume_hour",
            "per_floating_ip_hour",
        )
        if k in overrides
    }
    currency = str(overrides.get("currency", rates.currency))
    return replace(rates, currency=currency, flavor_hourly=table, **scalar)


def _default_rates_paths() -> tuple[Path, ...]:
    return (
        Path.home() / ".config" / "mgf-cloud" / "rates.toml",
        Path.home() / ".mgf" / "cloud-rates.toml",
    )


def rates_from_config(
    path: str | Path | None = None,
    *,
    environ: Mapping[str, str] | None = None,
) -> Rates:
    """Resolve :class:`Rates` from a TOML override file, else the built-in defaults.

    Search order: an explicit ``path`` → ``$MGF_CLOUD_RATES`` → the first existing
    of ``~/.config/mgf-cloud/rates.toml``, ``~/.mgf/cloud-rates.toml``. The file's
    keys mirror :class:`Rates` (scalars at top level, ``[flavor_hourly]`` as a
    table); a ``[rates]`` wrapper section is also accepted. An explicit ``path``
    that doesn't exist is an error; the env/default paths are best-effort.

    Example ``rates.toml``::

        currency = "EUR"
        per_vcpu_hour = 0.0018
        per_gb_volume_hour = 0.00005

        [flavor_hourly]
        "a4-ram8-disk20-perf1" = 0.0162
    """
    env = environ if environ is not None else os.environ
    explicit = path if path is not None else env.get(_RATES_ENV)
    if explicit is not None:
        p = Path(explicit)
        if not p.is_file():
            raise ConfigError(f"rates file not found: {p}")
        candidates: tuple[Path, ...] = (p,)
    else:
        candidates = _default_rates_paths()
    for candidate in candidates:
        if candidate.is_file():
            with candidate.open("rb") as fh:
                data = tomllib.load(fh)
            section = data.get("rates", data)
            return load_rates(section if isinstance(section, dict) else data)
    return Rates()
