"""Trial license — 14-day self-service Pro trial (online, server-bound, signed).

SECURITY MODEL (blocker G7 closure)
-----------------------------------
The trial is NOT a local convenience file anymore. Activation performs an
ONLINE round-trip to the license Worker (https://license.donjonsec.com).
The Worker records the machine fingerprint server-side (KV/D1) and returns
a SIGNED, time-bound (14-day) v2 Pro license. That signed blob is stored as
``data/trial-license.json`` and is honored ONLY after it passes the SAME
dual-signature verification chain as a paid license (see
``lib.licensing.LicenseManager``).

Consequences:
  * Deleting the local files does NOT re-grant Pro — the Worker remembers
    the fingerprint and refuses a second activation.
  * An unsigned / tampered ``trial-license.json`` is rejected (no valid
    signatures) and falls back to community.
  * If the Worker is unreachable, activation FAILS CLOSED (no offline Pro
    grant). Air-gapped users buy Pro; they do not trial.

This module is a thin client: it talks to the Worker and persists the
Worker-issued license. All trust decisions live in ``lib.licensing``.
"""
from __future__ import annotations

import json
import logging
import os
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Callable, Optional

logger = logging.getLogger(__name__)

TRIAL_DAYS = 14
TRIAL_TIER = "pro"

# License Worker base URL. Overridable via env for lab/CT107 testing, but
# defaults to the live production Worker. There is no offline fallback.
LICENSE_SERVER_URL = os.environ.get(
    "DONJON_LICENSE_SERVER", "https://license.donjonsec.com"
).rstrip("/")

_ACTIVATE_TRIAL_PATH = "/api/v1/trial/activate"
_HTTP_TIMEOUT_SECONDS = 15


def _get_data_dir() -> Path:
    """Get the data directory."""
    try:
        from lib.paths import paths
        return paths.data
    except Exception:
        return Path("data")


def _trial_path() -> Path:
    return _get_data_dir() / "trial-license.json"


def _marker_path() -> Path:
    return _get_data_dir() / ".trial-used"


def _machine_fingerprint() -> str:
    """Return the strong machine fingerprint used for all license binding.

    Reuses :func:`lib.licensing.generate_machine_fingerprint` so the trial
    is bound with the SAME identifier the signed license verifier checks
    (MAC + hostname + platform + CPU + OS machine-id), not the weak
    ``platform.node()``-only value the old trial used.
    """
    from lib.licensing import generate_machine_fingerprint
    return generate_machine_fingerprint()


# ---------------------------------------------------------------------------
# HTTP transport (stdlib only; injectable for tests)
# ---------------------------------------------------------------------------

def _http_post_json(url: str, data: dict[str, Any], timeout: int) -> tuple[int, Any]:
    """POST a JSON body and return (status_code, parsed_json_or_text).

    Raises urllib.error.URLError on connection failure so the caller can
    fail closed. Stdlib-only to keep the distributed product dependency-free.
    """
    body = json.dumps(data).encode("utf-8")
    req = urllib.request.Request(
        url,
        data=body,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:  # nosec B310 -- fixed https license endpoint
            raw = resp.read().decode("utf-8", "replace")
            status = resp.status
    except urllib.error.HTTPError as exc:
        raw = exc.read().decode("utf-8", "replace") if exc.fp else ""
        status = exc.code
    try:
        parsed: Any = json.loads(raw) if raw else {}
    except json.JSONDecodeError:
        parsed = raw
    return status, parsed


def _load_trial() -> Optional[dict[str, Any]]:
    """Load the stored (signed) trial license if present."""
    path = _trial_path()
    if not path.exists():
        return None
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return None


def handle(input_data: dict[str, Any],
           _http: Optional[Callable[[str, dict, int], tuple]] = None
           ) -> dict[str, Any]:
    """Handle trial license actions: activate, status, deactivate.

    ``_http`` is an injectable transport (signature: (url, data, timeout) ->
    (status, parsed)) used by tests to stand in for the network. Production
    callers omit it and the real :func:`_http_post_json` is used.
    """
    action = input_data.get("action", "status")
    post = _http or _http_post_json

    if action == "activate":
        return _activate(post)
    elif action == "status":
        return _status()
    elif action == "deactivate":
        return _deactivate()
    else:
        return {"error": f"Unknown action: {action}", "status": 400}


def _activate(post: Callable[[str, dict, int], tuple]) -> dict[str, Any]:
    """Activate a 14-day Pro trial via an ONLINE round-trip to the Worker.

    The Worker is the source of truth for whether this machine already used
    its trial. On success it returns a signed v2 Pro license which we persist
    and which is then validated locally by the signed-license verifier.

    Fails CLOSED on any network error: no offline Pro grant is ever written.
    """
    # A real paid license, if present, takes priority — no trial needed.
    if (_get_data_dir() / "license.json").exists():
        return {"error": "A license is already active — trial not needed", "status": 400}

    fingerprint = _machine_fingerprint()
    url = f"{LICENSE_SERVER_URL}{_ACTIVATE_TRIAL_PATH}"

    try:
        status, payload = post(url, {"action": "activate_trial",
                                     "fingerprint": fingerprint}, _HTTP_TIMEOUT_SECONDS)
    except (urllib.error.URLError, OSError, TimeoutError) as exc:
        logger.warning("Trial activation could not reach the license server: %s", exc)
        return {
            "activated": False,
            "tier": "community",
            "error": (
                "Could not reach the license server to start your trial. "
                "The Pro trial requires a one-time online activation. "
                "If you are offline or air-gapped, purchase a Pro license "
                "for fully offline use."
            ),
            "status": 503,
        }

    if not isinstance(payload, dict):
        return {"activated": False, "tier": "community",
                "error": "Unexpected response from license server.", "status": 502}

    # Worker refuses a fingerprint it has already seen.
    if status == 409 or payload.get("trial_used"):
        return {
            "activated": False,
            "tier": "community",
            "error": payload.get("error", "Trial already used on this machine."),
            "status": 409,
        }

    if status != 200:
        return {
            "activated": False,
            "tier": "community",
            "error": payload.get("error", "Trial activation was refused by the license server."),
            "status": status or 400,
        }

    signed_license = payload.get("license")
    if not isinstance(signed_license, dict) or "signatures" not in signed_license:
        return {"activated": False, "tier": "community",
                "error": "License server returned an unsigned trial.", "status": 502}

    # Persist the signed trial license. Trust is established later by the
    # signature verifier, not by the act of writing this file.
    _get_data_dir().mkdir(parents=True, exist_ok=True)
    with open(_trial_path(), "w", encoding="utf-8") as f:
        json.dump(signed_license, f, indent=2)
    # Local marker is a UX hint only; it carries no security weight.
    _marker_path().touch()

    trial_id = signed_license.get("license_id", payload.get("trial_id", ""))
    logger.info("Trial activated (server-issued): %s", trial_id)

    return {
        "activated": True,
        "expires": signed_license.get("expires", ""),
        "tier": TRIAL_TIER,
        "days_remaining": TRIAL_DAYS,
        "trial_id": trial_id,
    }


def _status() -> dict[str, Any]:
    """Report trial status by VALIDATING the stored signed trial.

    Tier is reported as 'pro' only if the stored license passes the full
    signed-license verification chain (signature + expiry + fingerprint).
    A missing, unsigned, tampered, expired, or wrong-machine file reports
    community.
    """
    trial = _load_trial()
    if not trial:
        return {
            "active": False,
            "trial_available": not _marker_path().exists(),
            "tier": "community",
            "days_remaining": 0,
            "expired": False,
        }

    # Verify through the SAME signed-trial path the LicenseManager uses to
    # decide the tier (Ed25519 signature + expiry + fingerprint + revocation).
    from lib.licensing import get_license_manager
    lm = get_license_manager()
    valid = False
    try:
        valid = (lm.get_tier() == "pro"
                 and isinstance(lm._license, dict)
                 and lm._license.get("trial") is True)
    except Exception as exc:  # pragma: no cover - defensive
        logger.debug("Trial validation error: %s", exc)
        valid = False

    if not valid:
        return {
            "active": False,
            "trial_available": False,
            "tier": "community",
            "days_remaining": 0,
            "expired": True,
        }

    # Recompute days remaining from the trial's own expiry for a stable
    # value regardless of which license object happens to be loaded.
    from datetime import datetime, timezone
    remaining = 0
    try:
        exp = datetime.fromisoformat(trial["expires"].replace("Z", "+00:00"))
        remaining = max(0, (exp - datetime.now(timezone.utc)).days)
    except Exception:
        remaining = 0

    return {
        "active": True,
        "trial_available": False,
        "tier": TRIAL_TIER,
        "days_remaining": remaining,
        "expired": False,
        "expires": trial.get("expires", ""),
        "trial_id": trial.get("license_id", trial.get("trial_id", "")),
    }


def _deactivate() -> dict[str, Any]:
    """Deactivate trial locally (remove the stored license file).

    Note: this does NOT free the trial on the server. The Worker still
    remembers the fingerprint, so re-activation remains refused.
    """
    path = _trial_path()
    if path.exists():
        path.unlink()
    return {"activated": False, "tier": "community"}
