"""License activation — Firebase server bind + local client state.

Flow:
  1. User enters PAYC-… activation code (no email / password)
  2. Client calls Cloud Function activateSafeguardLicense with
     licenseKey + machineId (HWID) — enforces maxActivations=1 and
     one free trial per PC (safeguard_trial_machines)
  3. On success, bind locally (offline grace: 7 days)
  4. Network down: fail closed unless the key exists in the local
     MVP registry (demo/dev only) — real trial/Pro keys need the server
"""

from __future__ import annotations

import hashlib
import json
import locale as locale_mod
import platform
import re
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any

import httpx

from runtime.paths import user_data_dir

_LICENSE_FILE = "license.json"
_REGISTRY_FILE = "license_registry.json"
_MACHINE_ID_FILE = "machine_id.txt"
_GRACE_DAYS = 7
_TRIAL_DAYS = 7
_KEY_PREFIX = "PAYC"
_CF_REGION = "europe-west1"
_CF_PROJECT = "paychek-trading"
_ACTIVATE_FN = "activateSafeguardLicense"


def _utcnow() -> datetime:
    return datetime.now(timezone.utc)


def _iso(dt: datetime | None) -> str | None:
    return dt.isoformat() if dt else None


def _parse_iso(raw: str | None) -> datetime | None:
    if not raw:
        return None
    try:
        return datetime.fromisoformat(raw)
    except ValueError:
        return None


def _license_path() -> Path:
    return user_data_dir() / _LICENSE_FILE


def _registry_path() -> Path:
    return user_data_dir() / _REGISTRY_FILE


def _machine_id_path() -> Path:
    return user_data_dir() / _MACHINE_ID_FILE


def _read_json(path: Path) -> dict[str, Any]:
    if not path.is_file():
        return {}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        return {}
    return data if isinstance(data, dict) else {}


def _write_json(path: Path, data: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")


def get_or_create_machine_id() -> str:
    path = _machine_id_path()
    if path.is_file():
        existing = path.read_text(encoding="utf-8").strip()
        if existing:
            return existing
    seed = "|".join(
        [
            platform.node(),
            platform.system(),
            platform.machine(),
            str(uuid.getnode()),
            secrets.token_hex(8),
        ]
    )
    machine_id = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:32]
    path.write_text(machine_id + "\n", encoding="utf-8")
    return machine_id


def machine_fingerprint() -> str:
    """Stable-ish fingerprint for this install (shown shortened in UI)."""
    mid = get_or_create_machine_id()
    raw = f"{mid}|{platform.node()}|{platform.system()}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:40]


def normalize_license_key(raw: str) -> str:
    """Normalize to PAYC-XXXX-XXXX-XXXX; extract embedded key from noisy paste."""
    text = (raw or "").upper()
    embedded = re.search(
        r"PAYC[\s\-]*([A-F0-9]{4})[\s\-]*([A-F0-9]{4})[\s\-]*([A-F0-9]{4})",
        text,
    )
    if embedded:
        return f"PAYC-{embedded.group(1)}-{embedded.group(2)}-{embedded.group(3)}"
    cleaned = re.sub(r"[^A-Za-z0-9]", "", text)
    payc_at = cleaned.find(_KEY_PREFIX)
    if payc_at >= 0:
        body = cleaned[payc_at + len(_KEY_PREFIX) :]
    else:
        body = cleaned
    hex_body = re.match(r"^[A-F0-9]+", body)
    body = hex_body.group(0) if hex_body else body
    # PAYC + 12 hex chars → PAYC-XXXX-XXXX-XXXX
    body = body[:12].ljust(12, "0") if len(body) < 12 else body[:12]
    parts = [body[i : i + 4] for i in range(0, 12, 4)]
    return f"{_KEY_PREFIX}-" + "-".join(parts)


def generate_license_key() -> str:
    body = secrets.token_hex(6).upper()  # 12 hex chars
    return normalize_license_key(_KEY_PREFIX + body)


def _load_registry() -> dict[str, Any]:
    data = _read_json(_registry_path())
    if "licenses" not in data or not isinstance(data["licenses"], dict):
        data = {"licenses": {}, "updated_at": None}
    return data


def _save_registry(data: dict[str, Any]) -> None:
    data["updated_at"] = _iso(_utcnow())
    _write_json(_registry_path(), data)


def mint_license(*, note: str = "manual", max_activations: int = 1) -> dict[str, Any]:
    """Issue a new unused license key (MVP registry on this machine / future server)."""
    key = generate_license_key()
    reg = _load_registry()
    while key in reg["licenses"]:
        key = generate_license_key()
    entry = {
        "key": key,
        "created_at": _iso(_utcnow()),
        "note": note,
        "max_activations": max(1, int(max_activations)),
        "activations": [],
        "revoked": False,
    }
    reg["licenses"][key] = entry
    _save_registry(reg)
    return dict(entry)


def ensure_demo_license() -> str | None:
    """Keep one unused DEMO key available for first-run testing."""
    reg = _load_registry()
    for entry in reg["licenses"].values():
        if entry.get("note") == "demo" and not entry.get("revoked"):
            if len(entry.get("activations") or []) < int(entry.get("max_activations") or 1):
                return str(entry["key"])
    minted = mint_license(note="demo", max_activations=1)
    return str(minted["key"])


def _load_local_license() -> dict[str, Any]:
    return _read_json(_license_path())


def _save_local_license(data: dict[str, Any]) -> None:
    _write_json(_license_path(), data)


def _activation_valid(local: dict[str, Any], now: datetime | None = None) -> tuple[bool, str]:
    now = now or _utcnow()
    if not local.get("activated"):
        return False, "not_activated"
    if local.get("revoked"):
        return False, "revoked"
    key = local.get("license_key")
    if not key:
        return False, "missing_key"
    fp = local.get("machine_fingerprint")
    if fp and fp != machine_fingerprint():
        return False, "machine_mismatch"

    last = _parse_iso(local.get("last_validated_at")) or _parse_iso(local.get("activated_at"))
    if last is None:
        return False, "missing_validation"
    grace_until = last + timedelta(days=_GRACE_DAYS)
    if now > grace_until:
        return False, "grace_expired"
    return True, "ok"


def _ensure_trial_started(local: dict[str, Any]) -> dict[str, Any]:
    """Start Lite trial on first launch (7 days)."""
    if local.get("trial_started_at"):
        return local
    local = dict(local)
    local["trial_started_at"] = _iso(_utcnow())
    _save_local_license(local)
    return local


def _trial_info(local: dict[str, Any], now: datetime | None = None) -> dict[str, Any]:
    now = now or _utcnow()
    started = _parse_iso(local.get("trial_started_at"))
    if started is None:
        return {
            "trial_active": False,
            "trial_started_at": None,
            "trial_ends_at": None,
            "trial_days_left": 0,
        }
    ends = started + timedelta(days=_TRIAL_DAYS)
    seconds_left = (ends - now).total_seconds()
    days_left = max(0, int((seconds_left + 86399) // 86400)) if seconds_left > 0 else 0
    return {
        "trial_active": now < ends,
        "trial_started_at": _iso(started),
        "trial_ends_at": _iso(ends),
        "trial_days_left": days_left,
    }


def _normalize_plan_name(raw: Any) -> str | None:
    plan = str(raw or "").strip().lower()
    if plan in {"trial", "lite", "pro"}:
        return plan
    if plan in {"free", "freemium"}:
        return "trial"
    if plan in {"paid", "annual", "yearly", "premium"}:
        return "pro"
    return None


def _infer_plan_from_dates(
    *,
    activated_at: datetime | None,
    expires_at: datetime | None,
    now: datetime | None = None,
) -> str | None:
    """Infer trial vs pro from expiry window when server plan is missing."""
    now = now or _utcnow()
    if expires_at is None:
        return "pro" if activated_at is not None else None
    start = activated_at or (expires_at - timedelta(days=_TRIAL_DAYS))
    span_days = (expires_at - start).total_seconds() / 86400.0
    # 7-day free trial keys expire ~7d after mint/activate; Pro is ~365d.
    if span_days <= float(_TRIAL_DAYS) + 1.5:
        return "trial"
    return "pro"


def _resolve_local_plan(local: dict[str, Any], *, bind_ok: bool, now: datetime | None = None) -> str:
    now = now or _utcnow()
    stored = _normalize_plan_name(local.get("plan") or local.get("license_plan"))
    expires_at = _parse_iso(local.get("expires_at")) if local.get("expires_at") else None
    activated_at = _parse_iso(local.get("activated_at"))
    inferred = _infer_plan_from_dates(
        activated_at=activated_at,
        expires_at=expires_at,
        now=now,
    )
    # Date window wins over a stale/wrong stored "pro" (older builds defaulted
    # missing plan → pro even for 7-day trial keys).
    if inferred == "trial" and stored in {None, "pro", "lite"}:
        plan = "trial"
    else:
        plan = stored or inferred or ("pro" if bind_ok else "lite")
    if plan == "lite" and bind_ok and inferred == "trial":
        plan = "trial"
    if plan == "trial" and expires_at is not None and now >= expires_at:
        return "lite"
    return plan


def _heal_stored_plan(local: dict[str, Any], plan: str) -> None:
    """Persist corrected plan when local license.json still has a wrong value."""
    if not local.get("activated"):
        return
    current = _normalize_plan_name(local.get("plan") or local.get("license_plan"))
    if current == plan:
        return
    if plan not in {"trial", "pro", "lite"}:
        return
    fixed = dict(local)
    fixed["plan"] = plan
    try:
        _save_local_license(fixed)
    except Exception:
        pass


def license_status() -> dict[str, Any]:
    from services.paychek_account import account_status

    local = _load_local_license()
    bind_ok, _reason = _activation_valid(local)
    account = account_status(refresh=True)
    registered_email = (
        str(local.get("registered_email") or "").strip().lower()
        or str(account.get("email") or "").strip().lower()
        or None
    )
    demo_key = None
    if not bind_ok:
        demo_key = ensure_demo_license()

    now = _utcnow()
    expires_at = _parse_iso(local.get("expires_at")) if local.get("expires_at") else None
    expired = expires_at is not None and now >= expires_at
    local_plan = _resolve_local_plan(local, bind_ok=bind_ok, now=now)

    # Access = Safeguard key bound on this PC (code-only activate).
    # Trial → plan "trial" (UI badge Lite). Paid annual → plan "pro".
    # Bound Safeguard key always wins over journal Pro (Paychek app entitlement).
    if bind_ok and not expired:
        if local_plan in {"trial", "pro"}:
            plan = local_plan
        else:
            # Bound but plan missing/odd — infer from expiry window, else pro.
            inferred = _infer_plan_from_dates(
                activated_at=_parse_iso(local.get("activated_at")),
                expires_at=expires_at,
                now=now,
            )
            plan = inferred if inferred in {"trial", "pro"} else "pro"
        can_use = True
        status_reason = "ok"
        _heal_stored_plan(local, plan)
    elif account.get("journal_pro"):
        plan = "pro"
        can_use = True
        status_reason = "ok"
    elif bind_ok and expired:
        plan = "lite"
        can_use = False
        status_reason = "expired"
    else:
        plan = "lite"
        can_use = False
        status_reason = "license_required"

    is_trial_plan = plan == "trial"
    trial_started = local.get("activated_at") if is_trial_plan else None
    trial_ends = _iso(expires_at) if is_trial_plan and expires_at else None
    trial_days_left = None
    if is_trial_plan and expires_at is not None:
        seconds_left = (expires_at - now).total_seconds()
        trial_days_left = max(0, int((seconds_left + 86399) // 86400)) if seconds_left > 0 else 0

    # Surface registered email for UI without Firebase password login.
    account_view = dict(account) if isinstance(account, dict) else {}
    if registered_email:
        account_view["email"] = registered_email
        account_view["logged_in"] = True
        account_view["reason"] = "ok"
        account_view["local_session"] = True

    last = _parse_iso(local.get("last_validated_at")) or _parse_iso(local.get("activated_at"))
    grace_until = (last + timedelta(days=_GRACE_DAYS)) if last else None
    activated = bool((bind_ok and not expired) or account.get("journal_pro"))
    return {
        "activated": activated,
        "plan": plan,
        "can_use": can_use,
        "reason": status_reason,
        "license_key": local.get("license_key") if bind_ok and not expired else None,
        "license_key_masked": _mask_key(local.get("license_key")) if local.get("license_key") else None,
        "registered_email": registered_email,
        "machine_fingerprint": machine_fingerprint(),
        "machine_fingerprint_short": machine_fingerprint()[:8].upper(),
        "activated_at": local.get("activated_at"),
        "last_validated_at": local.get("last_validated_at"),
        "grace_days": _GRACE_DAYS,
        "grace_until": _iso(grace_until),
        "expires_at": _iso(expires_at) if expires_at else None,
        "trial_days": _TRIAL_DAYS,
        "trial_started_at": trial_started,
        "trial_ends_at": trial_ends,
        "trial_days_left": trial_days_left,
        "demo_key": demo_key,
        "product": "Paychek Safeguard",
        "account": account_view,
        "signup_url": account_view.get("signup_url") or "https://paychek.pro/",
    }


def _mask_key(key: str | None) -> str | None:
    if not key:
        return None
    parts = key.split("-")
    if len(parts) < 2:
        return key[:4] + "••••"
    return parts[0] + "-" + "••••-" + "••••-" + parts[-1]


def _preferred_locale() -> str:
    try:
        raw = (locale_mod.getlocale()[0] or locale_mod.getdefaultlocale()[0] or "").lower()
    except Exception:
        raw = ""
    return "fr" if raw.startswith("fr") else "en"


class LicenseServerError(ValueError):
    """Business error from activateSafeguardLicense (not a network failure)."""

    def __init__(self, message: str, *, code: str | None = None):
        super().__init__(message)
        self.code = (code or "").strip().upper() or None


def _activate_callable_url() -> str:
    return (
        f"https://{_CF_REGION}-{_CF_PROJECT}.cloudfunctions.net/{_ACTIVATE_FN}"
    )


def call_activate_safeguard_license(
    *,
    license_key: str,
    machine_id: str,
    machine_name: str = "",
    locale: str = "en",
    id_token: str | None = None,
) -> dict[str, Any]:
    """POST Firebase callable activateSafeguardLicense.

    Auth Bearer is optional — activation works with licenseKey + machineId only.

    Raises:
      LicenseServerError: server rejected the activation (not-found, trial HWID, etc.)
      httpx.HTTPError / OSError: network / transport failure
    """
    headers: dict[str, str] = {"Content-Type": "application/json"}
    token = (id_token or "").strip()
    if token:
        headers["Authorization"] = f"Bearer {token}"

    data: dict[str, Any] = {
        "licenseKey": license_key,
        "machineId": machine_id,
        "machineName": (machine_name or "")[:120],
        "locale": "fr" if locale == "fr" else "en",
    }

    payload = {"data": data}
    url = _activate_callable_url()

    with httpx.Client(timeout=20.0) as client:
        response = client.post(url, headers=headers, json=payload)

    try:
        body = response.json() if response.content else {}
    except ValueError:
        body = {}
    if not isinstance(body, dict):
        body = {}

    err = body.get("error") if isinstance(body.get("error"), dict) else None
    if response.status_code >= 400 or err:
        msg = ""
        code = None
        if err:
            msg = str(err.get("message") or "").strip()
            code = str(err.get("status") or err.get("code") or "").strip() or None
        if not msg:
            msg = f"License server error (HTTP {response.status_code})."
        # Always surface that the cloud was reached + HTTP status (avoids
        # confusing this with the offline local-registry path).
        if f"HTTP {response.status_code}" not in msg:
            msg = f"{msg} (cloud HTTP {response.status_code})"
        raise LicenseServerError(msg, code=code)

    result = body.get("result")
    return result if isinstance(result, dict) else (body if body else {})


def _offline_activate_message(locale: str) -> str:
    if locale == "fr":
        return (
            "Impossible de joindre le serveur de licences. "
            "Connectez-vous à Internet pour activer une clé d’essai ou Pro."
        )
    return (
        "Cannot reach the license server. "
        "Connect to the internet to activate a trial or Pro key."
    )


def _persist_local_activation(
    *,
    key: str,
    machine_id: str,
    fingerprint: str,
    expires_at: str | None = None,
    source: str = "server",
    registered_email: str | None = None,
    plan: str | None = None,
) -> dict[str, Any]:
    now = _utcnow()
    previous = _load_local_license()
    email = (registered_email or previous.get("registered_email") or "").strip().lower() or None
    expires_dt = _parse_iso(expires_at) if expires_at else None
    resolved_plan = _normalize_plan_name(plan) or _infer_plan_from_dates(
        activated_at=now,
        expires_at=expires_dt,
        now=now,
    ) or "pro"
    local = {
        "activated": True,
        "license_key": key,
        "machine_id": machine_id,
        "machine_fingerprint": fingerprint,
        "activated_at": _iso(now),
        "last_validated_at": _iso(now),
        "revoked": False,
        "trial_started_at": previous.get("trial_started_at") or _iso(now),
        "expires_at": expires_at,
        "activation_source": source,
        "registered_email": email,
        "plan": resolved_plan,
    }
    _save_local_license(local)
    if email:
        try:
            from services.paychek_account import set_local_email_session

            set_local_email_session(email)
        except Exception:
            pass
    return license_status()


def _activate_via_local_registry(
    key: str,
    fingerprint: str,
    machine_id: str,
    registered_email: str | None = None,
) -> dict[str, Any]:
    """Dev/demo fallback when the Cloud Function is unreachable."""
    reg = _load_registry()
    entry = reg["licenses"].get(key)
    if entry is None:
        # Never reuse the old generic "License key not found" copy here —
        # that masked real cloud/network failures for users.
        raise ValueError(
            "Offline demo key not found on this PC. "
            "Trial/Pro keys from paychek.pro/licence require internet "
            f"(server: {_activate_callable_url()})."
        )
    if entry.get("revoked"):
        raise ValueError("This license key has been revoked.")

    activations: list[dict[str, Any]] = list(entry.get("activations") or [])
    max_act = max(1, int(entry.get("max_activations") or 1))

    already = next((a for a in activations if a.get("machine_fingerprint") == fingerprint), None)
    if already is None:
        if len(activations) >= max_act:
            raise ValueError(
                f"This license is already activated on {max_act} machine(s). "
                "Contact support to transfer."
            )
        activations.append(
            {
                "machine_fingerprint": fingerprint,
                "machine_id": machine_id,
                "activated_at": _iso(_utcnow()),
                "hostname": platform.node(),
            }
        )
        entry["activations"] = activations
        reg["licenses"][key] = entry
        _save_registry(reg)

    return _persist_local_activation(
        key=key,
        machine_id=machine_id,
        fingerprint=fingerprint,
        source="local_registry",
        registered_email=registered_email,
    )


def activate_license(raw_key: str) -> dict[str, Any]:
    key = normalize_license_key(raw_key)
    if not re.fullmatch(r"PAYC-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}", key):
        raise ValueError(
            "Format de clé invalide. Attendu : PAYC-XXXX-XXXX-XXXX."
            if _preferred_locale() == "fr"
            else "Invalid license key format. Expected PAYC-XXXX-XXXX-XXXX."
        )

    machine_id = get_or_create_machine_id()
    fingerprint = machine_fingerprint()
    locale = _preferred_locale()

    # Bearer is optional — preferred path is licenseKey + machineId only.
    id_token: str | None = None
    try:
        from services.paychek_account import get_id_token

        id_token = get_id_token(refresh=True)
    except Exception:
        id_token = None

    cloud_reached = False
    try:
        result = call_activate_safeguard_license(
            license_key=key,
            machine_id=machine_id,
            machine_name=platform.node(),
            locale=locale,
            id_token=id_token,
        )
        cloud_reached = True
    except LicenseServerError as exc:
        # Cloud answered — never fall back to local registry (that used to
        # rewrite server errors as a generic "key not found").
        cloud_reached = True
        msg = str(exc).strip() or "License activation rejected."
        code = (exc.code or "").upper()
        if code in {"NOT_FOUND", "404"} and "not found" in msg.lower():
            if "paychek.pro" not in msg.lower():
                msg = (
                    msg.rstrip(".")
                    + ". Copy the exact PAYC-XXXX-XXXX-XXXX code from paychek.pro/licence."
                )
        if "already used a Safeguard free trial" in msg or "déjà utilisé un essai" in msg:
            msg = (
                msg
                + f" (machine fingerprint {fingerprint[:8].upper()} — "
                "this is not a 'key not found' error)."
            )
        raise ValueError(msg) from exc
    except (httpx.HTTPError, OSError) as exc:
        # Transport failure only — demo/local keys may still activate offline.
        reg = _load_registry()
        if key not in (reg.get("licenses") or {}):
            detail = f"{type(exc).__name__}: {exc}"
            base = _offline_activate_message(locale)
            raise ValueError(
                f"{base} [{detail}] (cloud not reached: {_activate_callable_url()})"
            ) from None
        return _activate_via_local_registry(key, fingerprint, machine_id)

    if not cloud_reached:
        raise ValueError(_offline_activate_message(locale))

    if not result.get("ok"):
        raise ValueError(
            "Activation failed on the license server."
            if locale != "fr"
            else "Échec de l’activation sur le serveur de licences."
        )

    expires_at = result.get("expiresAt")
    if expires_at is not None:
        expires_at = str(expires_at)

    server_plan = _normalize_plan_name(
        result.get("plan") or result.get("type") or result.get("licensePlan")
    )

    return _persist_local_activation(
        key=str(result.get("licenseKey") or key),
        machine_id=machine_id,
        fingerprint=fingerprint,
        expires_at=expires_at,
        source="server",
        plan=server_plan,
    )


def validate_license(*, online: bool = True) -> dict[str, Any]:
    """Refresh validation timestamp.

    Cloud-bound keys (`activation_source=server`) stay valid for the offline
    grace window. Never check them against the MVP local registry — that path
    used to revoke every Pro/trial key immediately after a successful cloud
    activate (GET /license/status → activated=false).

    Local-registry / demo keys still verify license_registry.json when online.
    """
    local = _load_local_license()
    ok, reason = _activation_valid(local)
    if not ok and reason != "grace_expired":
        return license_status()

    key = local.get("license_key")
    fp = local.get("machine_fingerprint") or machine_fingerprint()
    source = str(local.get("activation_source") or "").strip().lower()

    # Only offline/dev activations are tied to the local JSON registry.
    if online and key and source in {"local_registry", "demo", "offline"}:
        reg = _load_registry()
        entry = reg["licenses"].get(key)
        if entry is None or entry.get("revoked"):
            local["revoked"] = True
            local["activated"] = False
            _save_local_license(local)
            return license_status()
        activations = entry.get("activations") or []
        if not any(a.get("machine_fingerprint") == fp for a in activations):
            local["revoked"] = True
            local["activated"] = False
            _save_local_license(local)
            return license_status()

    if ok:
        local["last_validated_at"] = _iso(_utcnow())
        local["revoked"] = False
        local["activated"] = True
        _save_local_license(local)

    return license_status()


def is_license_active() -> bool:
    """Pro license or active Lite trial — required for Ninja protection."""
    return license_status().get("can_use") is True


def deactivate_local_license() -> dict[str, Any]:
    """Clear local activation + registered email (keeps machine id)."""
    previous = _load_local_license()
    _save_local_license(
        {
            "activated": False,
            "license_key": None,
            "machine_id": get_or_create_machine_id(),
            "machine_fingerprint": machine_fingerprint(),
            "activated_at": None,
            "last_validated_at": None,
            "revoked": False,
            "trial_started_at": previous.get("trial_started_at") or _iso(_utcnow()),
            "expires_at": None,
            "activation_source": None,
            "registered_email": None,
            "plan": None,
        }
    )
    try:
        from services.paychek_account import logout_account

        logout_account()
    except Exception:
        pass
    return license_status()
