"""
scrylo/core/licence.py
──────────────────────
Licence validation for Scrylo.

Flow:
  1. Check ~/.scrylo/licence.token exists and is valid locally
  2. If not → prompt user for key → POST /activate to Worker
  3. On success → save token to ~/.scrylo/licence.token
  4. Every subsequent launch → verify token locally (no internet needed)
     → periodically re-verify online every 7 days silently

The token is a signed JWT-like string. If the Worker is unreachable on
first activation, a 3-day grace period is granted so the user isn't
locked out on day one due to a temporary network issue.
"""

from __future__ import annotations

import base64
import hashlib
import hmac
import json
import os
import platform
import sys
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

import httpx
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt
from rich.text import Text

# ── Constants ─────────────────────────────────────────────────────────────────

# Live activation server on Render
WORKER_URL      = "https://scrylo-license-server.onrender.com"

TOKEN_FILE      = Path.home() / ".scrylo" / "licence.token"
GRACE_FILE      = Path.home() / ".scrylo" / "licence.grace"
GRACE_DAYS      = 3          # days allowed if activation fails on first launch
VERIFY_INTERVAL = 7 * 86400  # re-verify online every 7 days (seconds)
REQUEST_TIMEOUT = 10          # seconds

console = Console()


# ── Public entry point ────────────────────────────────────────────────────────

def check_licence() -> None:
    """
    Called at the start of every CLI command.
    Exits the process with a clean message if not licenced.
    """
    # 1. Try local token first (fast path — no internet)
    token = _load_token()
    if token and _verify_token_locally(token):
        # Periodically re-verify online in the background
        _maybe_verify_online_async(token)
        return

    # 2. Check grace period (activation failed previously due to network)
    if _in_grace_period():
        console.print(
            "[dim]⚠  Licence activation pending (grace period active). "
            "Connect to the internet to complete activation.[/dim]"
        )
        return

    # 3. No valid token — prompt for key
    _prompt_and_activate()


# ── Activation flow ───────────────────────────────────────────────────────────

def _prompt_and_activate() -> None:
    """Show licence prompt, call Worker, save token or grant grace."""
    console.print()
    console.print(Panel(
        "[bold yellow]Scrylo — Licence Required[/bold yellow]\n\n"
        "[dim]Enter your licence key to activate Scrylo.\n"
        "Keys are delivered by email after purchase at [/dim]"
        "[yellow underline]scrylo.site[/yellow underline]\n\n"
        "[dim]Format: SCRYLO-XXXX-XXXX-XXXX-XXXX[/dim]",
        border_style="yellow",
        title="[dim]activation[/dim]",
    ))

    for attempt in range(3):
        key = Prompt.ask("\n[yellow]Licence key[/yellow]").strip().upper()

        if not key:
            continue

        if not _looks_like_key(key):
            console.print(
                "[red]✗ Invalid format.[/red] "
                "[dim]Expected: SCRYLO-XXXX-XXXX-XXXX-XXXX[/dim]"
            )
            continue

        console.print("[dim]  Activating...[/dim]", end="")

        result = _call_activate(key)

        if result["ok"]:
            TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
            TOKEN_FILE.write_text(result["token"])
            # Clear grace file if exists
            GRACE_FILE.unlink(missing_ok=True)
            console.print(f"\r[green]  ✓ Activated. {result.get('message', '')}[/green]\n")
            return

        error = result.get("error", "")

        if error == "invalid_key":
            console.print(
                f"\r[red]  ✗ Key not found.[/red] "
                f"[dim]Check your purchase email or contact scryloapp@gmail.com[/dim]"
            )
        elif error == "machine_limit":
            console.print(f"\r[red]  ✗ {result.get('message', 'Machine limit reached.')}[/red]")
        elif error == "revoked":
            console.print(f"\r[red]  ✗ {result.get('message', 'Key revoked.')}[/red]")
        elif error == "network":
            _grant_grace_period()
            console.print(
                "\r[yellow]  ⚠  Could not reach activation server.[/yellow]\n"
                f"[dim]  A {GRACE_DAYS}-day grace period has been granted. "
                "Connect to the internet to complete activation.[/dim]\n"
            )
            return
        else:
            console.print(f"\r[red]  ✗ Activation failed: {result.get('message', error)}[/red]")

    # 3 failed attempts
    console.print(
        "\n[red]Activation failed.[/red] "
        "[dim]Purchase Scrylo at scrylo.site or contact scryloapp@gmail.com[/dim]\n"
    )
    sys.exit(1)


def _call_activate(key: str) -> dict:
    """POST /activate to the Worker. Returns normalised result dict."""
    machine_id = _get_machine_id()
    try:
        resp = httpx.post(
            f"{WORKER_URL}/activate",
            json={"key": key, "machine_id": machine_id},
            timeout=REQUEST_TIMEOUT,
        )
        data = resp.json()
        if resp.status_code == 200 and data.get("ok"):
            return {"ok": True, "token": data["token"], "message": data.get("message", "")}
        return {"ok": False, "error": data.get("error", "unknown"), "message": data.get("message", "")}
    except Exception as e:
        return {"ok": False, "error": "network", "message": str(e)}


# ── Token helpers ─────────────────────────────────────────────────────────────

def _load_token() -> Optional[str]:
    try:
        if TOKEN_FILE.exists():
            return TOKEN_FILE.read_text().strip()
    except Exception:
        pass
    return None


def _verify_token_locally(token: str) -> bool:
    """
    Verify the token's structure and that machine_id matches.
    The Worker signs tokens with HMAC-SHA256 — we can't verify the
    cryptographic signature locally without the secret (which we don't
    embed in the binary for security). Instead we verify:
      - Token is parseable
      - machine_id matches this machine
      - Token is not obviously corrupted
    Full cryptographic verification happens on the Worker when we
    periodically re-verify online.
    """
    try:
        payload_b64, _sig = token.split(".")
        payload = json.loads(base64.b64decode(payload_b64 + "=="))
        return payload.get("machine_id") == _get_machine_id() and "key" in payload
    except Exception:
        return False


def _maybe_verify_online_async(token: str) -> None:
    """Re-verify token with the Worker every VERIFY_INTERVAL seconds."""
    try:
        stat = TOKEN_FILE.stat()
        age  = time.time() - stat.st_mtime
        if age < VERIFY_INTERVAL:
            return  # verified recently enough
    except Exception:
        return

    # Do it in a background thread so it doesn't slow down launch
    import threading

    def _verify():
        try:
            resp = httpx.post(
                f"{WORKER_URL}/verify",
                json={"token": token},
                timeout=REQUEST_TIMEOUT,
            )
            if resp.status_code == 200:
                # Touch the file to reset the timer
                TOKEN_FILE.touch()
            elif resp.status_code in (401, 403):
                # Key was revoked — clear token, user will be prompted next launch
                TOKEN_FILE.unlink(missing_ok=True)
                console.print(
                    "\n[red]⚠  Your Scrylo licence has been revoked.[/red] "
                    "[dim]Contact scryloapp@gmail.com[/dim]\n"
                )
        except Exception:
            pass  # silently ignore — offline is fine between launches

    threading.Thread(target=_verify, daemon=True).start()


# ── Grace period ──────────────────────────────────────────────────────────────

def _grant_grace_period() -> None:
    GRACE_FILE.parent.mkdir(parents=True, exist_ok=True)
    GRACE_FILE.write_text(str(time.time()))


def _in_grace_period() -> bool:
    try:
        if GRACE_FILE.exists():
            granted_at = float(GRACE_FILE.read_text().strip())
            if time.time() - granted_at < GRACE_DAYS * 86400:
                return True
            # Grace expired — remove file
            GRACE_FILE.unlink(missing_ok=True)
    except Exception:
        pass
    return False


# ── Machine ID ────────────────────────────────────────────────────────────────

def _get_machine_id() -> str:
    """
    Generates a stable, anonymous machine identifier.
    Derived from hardware/OS info — doesn't contain PII.
    Stored locally so it's consistent across reinstalls.
    """
    id_file = Path.home() / ".scrylo" / "machine.id"

    if id_file.exists():
        return id_file.read_text().strip()

    # Build from available system info
    bits = [
        platform.node(),
        platform.machine(),
        platform.processor(),
        str(uuid.getnode()),      # MAC address as int
    ]
    raw  = "|".join(bits).encode()
    mid  = hashlib.sha256(raw).hexdigest()[:32]

    id_file.parent.mkdir(parents=True, exist_ok=True)
    id_file.write_text(mid)
    return mid


# ── Validation ────────────────────────────────────────────────────────────────

def _looks_like_key(key: str) -> bool:
    """Quick format check before hitting the network."""
    parts = key.split("-")
    return (
        len(parts) == 5
        and parts[0] == "SCRYLO"
        and all(len(p) == 4 for p in parts[1:])
    )
