"""License schema — dataclasses + enums.

Wire format (the contents of ``~/.datatools/license.json`` AND the
base64-decoded activation blob)::

    {
      "name":         "Jane Doe",
      "email":        "jane@example.com",
      "license_key":  "DT1-CORE-1A2B3C4D-5E6F7G8H",
      "tier":         "core",
      "features":     ["01_deduplicator", "02_text_cleaner", ...],
      "issued_at":    "2026-05-13T00:00:00Z",
      "expires_at":   "2027-05-13T00:00:00Z",
      "signature":    "<hex hmac-sha256>"
    }

The signature is the HMAC over the canonical JSON of every field
*except* ``signature`` itself (see :mod:`.crypto`). Keeping the schema
strictly additive means future builds can verify older licenses as
long as they ship the same secret.
"""

from __future__ import annotations

from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any


class Tier(str, Enum):
    """License tier. Drives the feature set the active license unlocks.

    Order matters for upgrade pricing: LITE < CORE < PRO < ENTERPRISE.
    Each higher tier inherits every feature of the lower tiers — see
    :data:`.features.FEATURES_BY_TIER`.

    ``TRIAL`` is retained for backward-compat with the brief trial-
    enabled build, but is no longer reachable from any user-facing
    entry point (the GUI trial button and the ``license_cli trial``
    subcommand were both removed in v1.6). The enum value stays so
    a buyer with a trial license on disk doesn't see a load failure.
    """

    TRIAL = "trial"
    LITE = "lite"
    CORE = "core"
    PRO = "pro"
    ENTERPRISE = "enterprise"


class FeatureFlag(str, Enum):
    """Stable feature identifiers. Match the ``tool_id`` field in
    :mod:`src.gui.tools_registry` so the GUI's per-tool gating can
    share the same string keys.

    Future SKUs ship by adding new flags here and adding them to a
    new tier in ``FEATURES_BY_TIER`` — no consumer code changes.
    """

    DEDUPLICATOR = "01_deduplicator"
    TEXT_CLEANER = "02_text_cleaner"
    FORMAT_STANDARDIZER = "03_format_standardizer"
    MISSING_HANDLER = "04_missing_handler"
    COLUMN_MAPPER = "05_column_mapper"
    OUTLIER_DETECTOR = "06_outlier_detector"
    MULTI_FILE_MERGER = "07_multi_file_merger"
    VALIDATOR_REPORTER = "08_validator_reporter"
    PIPELINE_RUNNER = "09_pipeline_runner"


def _utcnow_iso() -> str:
    """Return current UTC time in ISO-8601 with explicit ``Z`` suffix.

    ``datetime.utcnow`` is deprecated in CPython 3.12; using a
    tz-aware UTC datetime and slicing off the ``+00:00`` keeps the
    serialized form short and human-readable.
    """
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _parse_iso(s: str) -> datetime:
    """Parse one of our ISO strings into a tz-aware datetime."""
    # Accept both ``...Z`` and ``...+00:00`` so future format tweaks
    # don't break old files.
    if s.endswith("Z"):
        s = s[:-1] + "+00:00"
    dt = datetime.fromisoformat(s)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt


@dataclass(frozen=True)
class License:
    """One activated license. Immutable — renew/upgrade produces a new
    instance, never mutates an existing one."""

    name: str
    email: str
    license_key: str
    tier: Tier
    features: tuple[str, ...]
    issued_at: str             # ISO-8601 UTC
    expires_at: str            # ISO-8601 UTC
    signature: str = ""        # populated by ``crypto.sign``

    # --- Convenience accessors ------------------------------------------------

    @property
    def issued_dt(self) -> datetime:
        return _parse_iso(self.issued_at)

    @property
    def expires_dt(self) -> datetime:
        return _parse_iso(self.expires_at)

    def is_expired(self, *, now: datetime | None = None) -> bool:
        ref = now or datetime.now(timezone.utc)
        return ref >= self.expires_dt

    def days_remaining(self, *, now: datetime | None = None) -> int:
        ref = now or datetime.now(timezone.utc)
        delta = self.expires_dt - ref
        # ``int(days)`` floors towards 0 for negatives — we use
        # ``max(..., 0)`` for the display path and the raw value for
        # the test path. Callers wanting "expired by N days" should
        # use ``is_expired`` first.
        return delta.days

    def has_feature(self, feature: str | FeatureFlag) -> bool:
        key = feature.value if isinstance(feature, FeatureFlag) else feature
        return key in self.features

    # --- Serialization --------------------------------------------------------

    def to_canonical_dict(self) -> dict[str, Any]:
        """Return the JSON-canonical dict the HMAC is computed over.

        Excludes ``signature`` so signing and verifying both agree on
        the message bytes.
        """
        d = asdict(self)
        d.pop("signature", None)
        d["tier"] = self.tier.value if isinstance(self.tier, Tier) else self.tier
        d["features"] = list(self.features)
        return d

    def to_dict(self) -> dict[str, Any]:
        """Return the on-disk dict, signature included."""
        d = self.to_canonical_dict()
        d["signature"] = self.signature
        return d

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "License":
        """Inverse of :meth:`to_dict`. Tolerant of missing optional
        fields (defaults), strict on required ones (raises KeyError).
        """
        tier_raw = data["tier"]
        tier = tier_raw if isinstance(tier_raw, Tier) else Tier(tier_raw)
        return cls(
            name=str(data["name"]),
            email=str(data["email"]),
            license_key=str(data["license_key"]),
            tier=tier,
            features=tuple(data.get("features", ())),
            issued_at=str(data["issued_at"]),
            expires_at=str(data["expires_at"]),
            signature=str(data.get("signature", "")),
        )


# Public helper exposed for the activation flow (1-year default).
def default_expiry_iso(years: int = 1, *, now: datetime | None = None) -> str:
    """Return an ISO timestamp *years* from *now* (default: current UTC)."""
    ref = now or datetime.now(timezone.utc)
    # ``replace(year=...)`` handles leap-year edge cases via the
    # ``timedelta`` fallback below for Feb-29 issued dates.
    try:
        target = ref.replace(year=ref.year + years)
    except ValueError:
        # Feb 29 + N years where target year isn't a leap year — slide
        # to Feb 28. Acceptable; the buyer is one day short of an
        # exact year boundary on a date they almost certainly didn't
        # pick on purpose.
        target = ref.replace(year=ref.year + years, day=28)
    return target.strftime("%Y-%m-%dT%H:%M:%SZ")
