"""Shared dollar-denominated usage model for the billing/subscription surfaces.

The single source of truth behind the ``/usage`` and ``/subscription`` usage
bars (TUI + CLI). User feedback (Jun 2026): the terminal surfaces show
**dollars**, never "credits", and every usage bar must make the monthly
subscription allowance and separately-purchased top-up dollars distinctly
visible.

Data source: the NAS account-info fetch (``NousPortalAccountInfo``), whose
``paid_service_access_info`` carries the three dollar magnitudes we render
(despite the legacy ``*_credits`` field names, these are USD floats):

  - ``subscription_credits_remaining``  -> plan dollars left this month
  - ``purchased_credits_remaining``     -> top-up dollars left (rolls over)
  - ``total_usable_credits``            -> total spendable

plus ``subscription.monthly_credits`` (the plan's monthly $ allowance, the
denominator for the "% used" plan bar) and ``current_period_end`` (renewal).

Design: two SEPARATE bars (decided with the user) rather than one crammed
three-segment bar — at terminal widths three same-glyph density segments are
unreadable. The plan bar is "spent vs allowance this month" (carries % used);
the top-up bar is "money you bought, doesn't expire". Each gets full
resolution and a single fill glyph, so the bar is never ambiguous and never
relies on color.

Fail-open everywhere: any missing/non-finite field degrades to fewer bars or a
magnitudes-only view; a logged-out / unreachable portal yields
``available=False`` and the surface shows nothing.
"""

from __future__ import annotations

import logging
import math
import os
from dataclasses import dataclass
from typing import Any, Optional

logger = logging.getLogger(__name__)

# Below this TOTAL spendable ($), a paid account is flagged "low" — the alert
# state that nudges top-up/upgrade before a mid-run cutoff. Product threshold
# (user feedback): "any amount below $5 should be an alert status."
LOW_BALANCE_THRESHOLD_USD = 5.0


def _finite(value: Any) -> Optional[float]:
    """Return value as a float iff it's a real finite number (not bool/NaN/Inf)."""
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        return None
    f = float(value)
    return f if math.isfinite(f) else None


def _fmt_usd(value: Optional[float]) -> str:
    """``$X.YY`` for display. ``None`` -> ``$0.00`` (callers gate on presence)."""
    return f"${(value or 0.0):,.2f}"


def format_renews(value: Optional[str]) -> Optional[str]:
    """Format an ISO date/timestamp as a human date, e.g. ``Jul 24, 2026``.

    Accepts ``2026-07-24``, ``2026-07-24T11:05:01.000Z``, etc. Returns the raw
    string unchanged if it can't be parsed (never raises), and ``None`` for
    empty input.
    """
    if not value:
        return None
    from datetime import datetime

    text = str(value).strip()
    if not text:
        return None
    iso = text[:-1] + "+00:00" if text.endswith("Z") else text
    try:
        dt = datetime.fromisoformat(iso)
    except ValueError:
        # Fall back to a bare date prefix (YYYY-MM-DD) if present.
        try:
            dt = datetime.strptime(text[:10], "%Y-%m-%d")
        except ValueError:
            return text
    # %-d isn't portable to Windows; build the day without a leading zero.
    return f"{dt.strftime('%b')} {dt.day}, {dt.year}"


@dataclass(frozen=True)
class UsageBar:
    """One full-resolution bar: ``spent`` of ``total``, plus a remaining figure.

    ``kind`` is ``"plan"`` (monthly allowance, shows % used) or ``"topup"``
    (purchased dollars, no denominator — ``spent`` is 0 and ``total`` ==
    ``remaining`` so it renders as a full bar of available balance).
    """

    kind: str  # "plan" | "topup"
    remaining_usd: float
    total_usd: float
    spent_usd: float = 0.0

    @property
    def pct_used(self) -> Optional[int]:
        if self.kind != "plan" or self.total_usd <= 0:
            return None
        return max(0, min(100, round(self.spent_usd / self.total_usd * 100)))

    @property
    def fill_fraction(self) -> float:
        """Fraction of the bar that should read as 'remaining' (filled)."""
        if self.total_usd <= 0:
            return 0.0
        return max(0.0, min(1.0, self.remaining_usd / self.total_usd))


@dataclass(frozen=True)
class UsageModel:
    """Surface-agnostic dollar usage model shared by /usage and /subscription.

    ``status`` classifies the account for copy selection:
      - ``"free"``     : no paid access / no subscription (free models only)
      - ``"low"``      : paid, but total spendable < $5 (ALERT)
      - ``"healthy"``  : paid, total spendable >= $5
      - ``"depleted"`` : paid access lost (balance exhausted)
    """

    available: bool
    status: str = "free"
    plan_name: Optional[str] = None
    renews_at: Optional[str] = None
    renews_display: Optional[str] = None
    subscription_remaining_usd: Optional[float] = None
    topup_remaining_usd: Optional[float] = None
    total_spendable_usd: Optional[float] = None
    plan_bar: Optional[UsageBar] = None
    topup_bar: Optional[UsageBar] = None

    @property
    def has_topup(self) -> bool:
        return bool(self.topup_remaining_usd and self.topup_remaining_usd > 0)


def usage_model_from_account(account_info: Any) -> UsageModel:
    """Build a :class:`UsageModel` from a ``NousPortalAccountInfo``. Fail-open.

    Returns ``UsageModel(available=False)`` when there's no usable account info
    (logged out, no entitlement block). Never raises.
    """
    try:
        if account_info is None or not getattr(account_info, "logged_in", False):
            return UsageModel(available=False)

        access = getattr(account_info, "paid_service_access_info", None)
        sub = getattr(account_info, "subscription", None)
        paid = getattr(account_info, "paid_service_access", None)

        sub_remaining = _finite(getattr(access, "subscription_credits_remaining", None)) if access else None
        topup_remaining = _finite(getattr(access, "purchased_credits_remaining", None)) if access else None
        total_usable = _finite(getattr(access, "total_usable_credits", None)) if access else None

        plan_name = getattr(sub, "plan", None) if sub is not None else None
        renews_at = getattr(sub, "current_period_end", None) if sub is not None else None
        monthly = _finite(getattr(sub, "monthly_credits", None)) if sub is not None else None

        has_subscription = bool(plan_name) or (monthly is not None and monthly > 0)

        # Total spendable: prefer the server's total; else sum the parts we have.
        if total_usable is not None:
            total_spendable = total_usable
        else:
            parts = [v for v in (sub_remaining, topup_remaining) if v is not None]
            total_spendable = sum(parts) if parts else None

        # Status classification.
        if paid is False:
            status = "depleted"
        elif not has_subscription and not (topup_remaining and topup_remaining > 0):
            # No plan and no purchased balance -> free-models-only.
            status = "free"
        elif total_spendable is not None and total_spendable < LOW_BALANCE_THRESHOLD_USD:
            status = "low"
        else:
            status = "healthy"

        # Plan bar — only with a positive monthly allowance AND a remaining we
        # can place on it. spent = cap - remaining, clamped (a debt/over-cap
        # balance reads as fully spent rather than a nonsensical negative).
        plan_bar: Optional[UsageBar] = None
        if monthly is not None and monthly > 0 and sub_remaining is not None:
            remaining = max(0.0, min(monthly, sub_remaining))
            plan_bar = UsageBar(
                kind="plan",
                remaining_usd=remaining,
                total_usd=monthly,
                spent_usd=max(0.0, monthly - sub_remaining),
            )

        # Top-up bar — only when there are purchased dollars to show. No
        # denominator (top-up has no monthly cap), so it renders full = balance.
        topup_bar: Optional[UsageBar] = None
        if topup_remaining is not None and topup_remaining > 0:
            topup_bar = UsageBar(
                kind="topup",
                remaining_usd=topup_remaining,
                total_usd=topup_remaining,
                spent_usd=0.0,
            )

        return UsageModel(
            available=True,
            status=status,
            plan_name=plan_name,
            renews_at=renews_at,
            renews_display=format_renews(renews_at),
            subscription_remaining_usd=sub_remaining,
            topup_remaining_usd=topup_remaining,
            total_spendable_usd=total_spendable,
            plan_bar=plan_bar,
            topup_bar=topup_bar,
        )
    except Exception:
        logger.debug("usage ▸ model build failed (fail-open)", exc_info=True)
        return UsageModel(available=False)


def build_usage_model(*, timeout: float = 10.0) -> UsageModel:
    """Fetch account-info and build the shared usage model. Fail-open.

    Dev override: ``HERMES_DEV_CREDITS_FIXTURE`` short-circuits to a fixture so
    every usage state is testable without a live account (mirrors the existing
    ``/usage`` credits-block fixture path).
    """
    fixture = _dev_fixture_usage_model()
    if fixture is not None:
        return fixture

    try:
        from hermes_cli.auth import get_provider_auth_state

        tok = (get_provider_auth_state("nous") or {}).get("access_token")
        if not (isinstance(tok, str) and tok.strip()):
            return UsageModel(available=False)
    except Exception:
        return UsageModel(available=False)

    try:
        import concurrent.futures

        from hermes_cli.nous_account import get_nous_portal_account_info

        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
            account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(timeout=timeout)
        return usage_model_from_account(account)
    except Exception:
        logger.debug("usage ▸ portal fetch failed (fail-open)", exc_info=True)
        return UsageModel(available=False)


# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================


def _dev_fixture_usage_model() -> Optional[UsageModel]:
    """Map ``HERMES_DEV_CREDITS_FIXTURE`` to a usage model for offline UX work.

    Recognized names: ``free | healthy | low | topup | depleted``. Returns
    ``None`` when the env var is unset (real portal path runs).
    """
    name = (os.getenv("HERMES_DEV_CREDITS_FIXTURE") or "").strip().lower()
    if not name:
        return None

    if name == "free":
        return UsageModel(available=True, status="free", plan_name=None)

    if name in ("healthy", "mid"):
        return UsageModel(
            available=True,
            status="healthy",
            plan_name="Plus",
            renews_at="2026-07-01",
            subscription_remaining_usd=14.0,
            total_spendable_usd=14.0,
            plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
        )

    if name in ("topup", "top-up"):
        return UsageModel(
            available=True,
            status="healthy",
            plan_name="Plus",
            renews_at="2026-07-01",
            subscription_remaining_usd=14.0,
            topup_remaining_usd=12.0,
            total_spendable_usd=26.0,
            plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
            topup_bar=UsageBar(kind="topup", remaining_usd=12.0, total_usd=12.0, spent_usd=0.0),
        )

    if name == "low":
        return UsageModel(
            available=True,
            status="low",
            plan_name="Plus",
            renews_at="2026-07-01",
            subscription_remaining_usd=3.4,
            total_spendable_usd=3.4,
            plan_bar=UsageBar(kind="plan", remaining_usd=3.4, total_usd=20.0, spent_usd=16.6),
        )

    if name == "depleted":
        return UsageModel(
            available=True,
            status="depleted",
            plan_name="Plus",
            renews_at="2026-07-01",
            subscription_remaining_usd=0.0,
            total_spendable_usd=0.0,
            plan_bar=UsageBar(kind="plan", remaining_usd=0.0, total_usd=20.0, spent_usd=20.0),
        )

    return None
