"""Account-based licensing — magic-link login + signed device certs.

How it works:

  1. User enters email in Lirelle's Account pane → `request_magic_link(email)`
     calls POST /auth/request-link. Server emails a 6-char code.
  2. User pastes the code → `activate_with_code(email, code)` calls
     POST /auth/verify-code with this Mac's machine_id + machine_name.
     Server returns:
         { session_token, plan, subscription_valid_until, device_cert }
     We save:
         ~/Library/Application Support/Lumo/license.json   ← signed cert
         ~/Library/Application Support/Lumo/session.json   ← {token, email}
  3. Every dictation calls `load_license()` which reads license.json,
     verifies the Ed25519 signature offline, and returns plan + caps.
  4. Certs have a 2-week TTL (server-side). When near expiry, the app calls
     `refresh_cert()` to get a fresh one (uses session.json for auth).
     Falls back to Free if refresh fails.

Phase 2 (Mac App Store) will replace this module's network calls with a
StoreKit-receipt check; the public surface (`load_license`, `current_plan`,
`activate_with_code`) stays identical so the UI and gates don't change.
"""

from __future__ import annotations

import base64
import functools
import json
import subprocess
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path

try:
    from nacl.signing import VerifyKey  # type: ignore
    from nacl.exceptions import BadSignatureError  # type: ignore
    _HAS_NACL = True
except ImportError:
    _HAS_NACL = False

# Public Ed25519 key matching the server's private signing key. Replacing this
# invalidates every cert in the wild — only change in lock-step with a server
# keypair rotation.
LICENSE_PUBLIC_KEY_B64 = "i58cF70/i3YXj0xyS4GmFFGqEP+tydAXFHoAmhU1ROM="

# SECURITY-CRITICAL. The env-var override only works in unfrozen dev runs
# (`python -m src.app`); in a shipped .app sys.frozen is set by py2app and
# the flag is forced False, so a customer can't drop `{"plan":"plus"...}`
# into license.json and bypass the Ed25519 check by exporting an env var.
# Audit #2 (2026-06-10).
import os as _os_for_flag
import sys as _sys_for_flag
LICENSE_DEV_ACCEPT_UNSIGNED = (
    not getattr(_sys_for_flag, "frozen", False)
    and _os_for_flag.environ.get("LUMO_DEV_ACCEPT_UNSIGNED") == "1"
)

# Where account API calls go. Resolution order:
#   1. LUMO_LICENSE_SERVER environment variable (per-user override, never baked
#      into a release; useful for staging vs prod testing on the same Mac).
#   2. The default literal below — this is what ships in built `.app` bundles
#      and IS rewritten by scripts/build_app.sh during release builds before
#      py2app freezes the source. Local dev keeps localhost.
# Touch the default carefully: a release built with localhost can't talk to
# any real customer's machine and silently degrades every paid user to Free.
import os as _os_for_url
LICENSE_SERVER_URL = (
    _os_for_url.environ.get("LUMO_LICENSE_SERVER")
    or "https://lirelle.us"
)

PLAN_FREE = "free"
PLAN_LITE = "lite"
PLAN_PLUS = "plus"
VALID_PLANS = (PLAN_FREE, PLAN_LITE, PLAN_PLUS)

CLEANUP_BY_PLAN = {
    PLAN_FREE: {"raw"},
    PLAN_LITE: {"raw", "light"},
    PLAN_PLUS: {"raw", "light", "standard", "polished"},
}

# Word-cap per local day; None = unlimited. Source of truth on the client;
# the SERVER doesn't enforce a per-day cap (the client gates locally so
# Free dictations don't even leave the Mac), it just sells the tier.
# emails.py on the server side keeps its OWN literal in user-facing copy;
# the two MUST agree — anytime this number changes, update the welcome /
# expiry email templates too.
DAILY_WORD_CAP = {
    PLAN_FREE: 2000,
    PLAN_LITE: None,
    PLAN_PLUS: None,
}

# Monthly + yearly pricing in rubles. The SERVER's PRICE_LITE_RUB /
# PRICE_PLUS_RUB env vars are the authoritative values for what Robokassa
# actually charges; these client-side constants exist purely so the bank
# window's plan catalogue can render prices without an extra HTTP call.
# Keep them in lock-step — if you change one, change both deploys.
PLAN_PRICE_RUB_MONTHLY = {PLAN_LITE: 299, PLAN_PLUS: 899}
PLAN_PRICE_RUB_YEARLY = {PLAN_LITE: 2390, PLAN_PLUS: 7190}


def yearly_discount_pct(plan: str) -> int:
    """Display-only: percent saved buying yearly vs monthly. Computed from
    the two PLAN_PRICE_RUB_* tables so the savings string in the bank window
    can't drift out of sync with the underlying numbers."""
    m = PLAN_PRICE_RUB_MONTHLY.get(plan)
    y = PLAN_PRICE_RUB_YEARLY.get(plan)
    if not m or not y:
        return 0
    return int(round((1 - (y / (m * 12))) * 100))

# Refresh proactively once the cert is older than this — the server-side TTL is
# 14 days, so refreshing every ~7 days keeps a buffer if the user is offline.
CERT_REFRESH_AFTER_DAYS = 7


# ── Paths + machine identity ────────────────────────────────────────────

def _data_dir() -> Path:
    return Path.home() / "Library" / "Application Support" / "Lumo"


def license_path() -> Path:
    return _data_dir() / "license.json"


def session_path() -> Path:
    return _data_dir() / "session.json"


@functools.lru_cache(maxsize=1)
def machine_id() -> str:
    """Stable hardware UUID — binds the cert to a single Mac. Survives OS
    reinstalls. We read from IOKit so it's the same value the server's
    device-binding logic compares against.
    Cached: the UUID is constant for the lifetime of the process, but the
    bank's 1.5s timer kept shelling out to `ioreg` every tick when the
    Account pane was visible. Each call was 10-30 ms on the main thread."""
    try:
        out = subprocess.run(
            ["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
            capture_output=True, text=True, timeout=2.0, check=True,
        ).stdout
        for line in out.splitlines():
            if "IOPlatformUUID" in line:
                return line.split('=')[-1].strip().strip('"')
    except Exception as e:
        print(f"[licensing] machine_id read failed: {e}")
    return "unknown"


@functools.lru_cache(maxsize=1)
def machine_name() -> str:
    """Human-readable name shown in the web cabinet's device list. Falls back
    to plain hostname if scutil isn't available. Cached for the same reason
    as machine_id — process-lifetime constant, called from a 1.5s UI timer."""
    try:
        out = subprocess.run(
            ["scutil", "--get", "ComputerName"],
            capture_output=True, text=True, timeout=2.0, check=True,
        ).stdout.strip()
        if out:
            return out
    except Exception:
        pass
    import socket
    try:
        return socket.gethostname()
    except Exception:
        return "Mac"


# ── State ────────────────────────────────────────────────────────────────

@dataclass
class LicenseState:
    plan: str
    email: str | None                       # the account that owns this cert
    subscription_valid_until: datetime | None  # None for Free / never-subscribed
    cert_expires_at: datetime | None        # when the cert itself needs refresh
    machine_id: str | None
    raw: dict | None
    error: str | None = None

    @property
    def is_active(self) -> bool:
        if self.plan == PLAN_FREE:
            return True
        if self.subscription_valid_until is None:
            return False
        return datetime.now(timezone.utc) <= self.subscription_valid_until

    @property
    def allows_cleanup_levels(self) -> set[str]:
        return CLEANUP_BY_PLAN.get(self.plan, CLEANUP_BY_PLAN[PLAN_FREE])

    @property
    def daily_word_cap(self) -> int | None:
        return DAILY_WORD_CAP.get(self.plan, DAILY_WORD_CAP[PLAN_FREE])

    @property
    def needs_refresh(self) -> bool:
        """Cert is older than CERT_REFRESH_AFTER_DAYS — try to fetch a new one."""
        if self.cert_expires_at is None:
            return False
        age_until_expiry = self.cert_expires_at - datetime.now(timezone.utc)
        return age_until_expiry.days <= (14 - CERT_REFRESH_AFTER_DAYS)


def _free_state(**overrides) -> LicenseState:
    base = dict(
        plan=PLAN_FREE,
        email=None,
        subscription_valid_until=None,
        cert_expires_at=None,
        machine_id=None,
        raw=None,
        error=None,
    )
    base.update(overrides)
    return LicenseState(**base)


# ── Crypto ───────────────────────────────────────────────────────────────

def _verify_signature(payload: dict) -> bool:
    if not _HAS_NACL or not LICENSE_PUBLIC_KEY_B64:
        return False
    sig_b64 = payload.get("signature")
    if not sig_b64:
        return False
    body = {k: v for k, v in payload.items() if k != "signature"}
    canon = json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
    try:
        vk = VerifyKey(base64.b64decode(LICENSE_PUBLIC_KEY_B64))
        vk.verify(canon, base64.b64decode(sig_b64))
        return True
    except (BadSignatureError, ValueError, Exception):
        return False


def _parse_iso(s: str | None) -> datetime | None:
    if not s:
        return None
    try:
        return datetime.fromisoformat(s.replace("Z", "+00:00"))
    except (ValueError, AttributeError):
        return None


# ── Clock-rollback guard (audit #4) ─────────────────────────────────────
#
# `load_license` and `LicenseState.is_active` both compare against
# `datetime.now()` — the local OS clock. A user who paid for one month can
# extend their Plus access indefinitely by rolling System Settings → Date
# & Time back before `subscription_valid_until`. The mitigation is a
# monotonic high-water-mark: each launch we record the latest "trusted"
# wall-clock we've seen, and if the next launch's `now` is earlier than
# that (minus a small skew tolerance) we treat the clock as tampered and
# require a successful network refresh before honouring a paid plan.

_CLOCK_SKEW_TOLERANCE_S = 300  # 5 min — DST + timezone + sync slop


def _clock_anchor_path() -> Path:
    return _data_dir() / "_clock_anchor"


def _read_clock_anchor() -> datetime | None:
    try:
        raw = _clock_anchor_path().read_text(encoding="utf-8").strip()
    except OSError:
        return None
    return _parse_iso(raw)


def _write_clock_anchor(ts: datetime) -> None:
    """Atomically advance the high-water-mark. Never written backwards —
    if `ts` is older than what's on disk we keep the older-but-higher one."""
    try:
        _data_dir().mkdir(parents=True, exist_ok=True)
        existing = _read_clock_anchor()
        chosen = ts if (existing is None or ts > existing) else existing
        _clock_anchor_path().write_text(
            chosen.astimezone(timezone.utc).isoformat(timespec="seconds"),
            encoding="utf-8",
        )
        # The anchor leaks no PII but there's no reason for any other user
        # to read it; match the rest of the data dir's discipline.
        try:
            _clock_anchor_path().chmod(0o600)
        except OSError:
            pass
    except OSError as e:
        print(f"[licensing] clock anchor write failed (non-fatal): {e}",
              flush=True)


def _clock_appears_rolled_back(now: datetime) -> bool:
    anchor = _read_clock_anchor()
    if anchor is None:
        return False
    # Treat as rollback only if the clock is more than the tolerance window
    # earlier than the highest timestamp we've ever recorded.
    return now < (anchor - timedelta(seconds=_CLOCK_SKEW_TOLERANCE_S))


# ── Load + persist ──────────────────────────────────────────────────────

def load_license() -> LicenseState:
    """Read the device cert from disk, verify it, return a usable LicenseState.
    Any failure mode falls back to Free — Lirelle is always usable in Free mode."""
    path = license_path()
    if not path.exists():
        return _free_state()
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as e:
        return _free_state(error=f"license file unreadable: {e}")

    if not _verify_signature(data) and not LICENSE_DEV_ACCEPT_UNSIGNED:
        return _free_state(raw=data, error="signature invalid")

    bound = data.get("machine_id")
    if bound and bound != machine_id():
        return _free_state(raw=data, machine_id=bound,
                           error="cert belongs to a different Mac")

    plan = data.get("plan", PLAN_FREE)
    if plan not in VALID_PLANS:
        return _free_state(raw=data, error=f"unknown plan: {plan!r}")

    sub_vu = _parse_iso(data.get("subscription_valid_until"))
    cert_exp = _parse_iso(data.get("expires_at"))

    now = datetime.now(timezone.utc)

    # Audit #4 — clock-rollback guard. If the OS clock is well behind the
    # latest "trusted" time we've recorded, treat the cert as expired
    # rather than honour what the user's clock claims. Refusing to honour
    # the cert here forces a fall back to Free until either (a) the clock
    # advances past the anchor again, or (b) a future cert refresh comes
    # back from the server with a fresh `subscription_valid_until`.
    if plan != PLAN_FREE and _clock_appears_rolled_back(now):
        return _free_state(
            email=data.get("email"), raw=data, machine_id=bound,
            error="system clock appears to have been moved backwards — "
                  "reconnect to network to refresh",
        )

    # Subscription expired → fall back to free, but keep email visible.
    if plan != PLAN_FREE and (sub_vu is None or sub_vu < now):
        return _free_state(
            email=data.get("email"), raw=data, machine_id=bound,
            error="subscription expired — renew from the web cabinet",
        )

    # Advance the high-water-mark on each successful active load. We only
    # record the wall clock when we've already passed every other check —
    # this way a tampered clock that happens before any check doesn't
    # poison the anchor.
    _write_clock_anchor(now)

    return LicenseState(
        plan=plan,
        email=data.get("email"),
        subscription_valid_until=sub_vu,
        cert_expires_at=cert_exp,
        machine_id=bound,
        raw=data,
    )


def _install_cert(cert: dict) -> LicenseState:
    _data_dir().mkdir(parents=True, exist_ok=True)
    p = license_path()
    p.write_text(json.dumps(cert, ensure_ascii=False, indent=2),
                 encoding="utf-8")
    # Audit cleanup — match session.json's 0600. The cert isn't itself a
    # secret (it's already Ed25519-signed and includes machine_id), but
    # any process running as the same user could read it and impersonate
    # the cert with the server during refresh otherwise.
    try:
        p.chmod(0o600)
    except OSError as e:
        print(f"[licensing] chmod license.json failed (non-fatal): {e}",
              flush=True)
    return load_license()


def _save_session(token: str, email: str) -> None:
    _data_dir().mkdir(parents=True, exist_ok=True)
    session_path().write_text(
        json.dumps({"token": token, "email": email}, ensure_ascii=False),
        encoding="utf-8",
    )
    # Tighten permissions — token is sensitive.
    try:
        session_path().chmod(0o600)
    except OSError:
        pass


def _load_session() -> dict | None:
    p = session_path()
    if not p.exists():
        return None
    try:
        return json.loads(p.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return None


def remove_license() -> None:
    """Delete the local cert + session — user reverts to Free. Caller MUST
    confirm with the user before invoking; this is the only path in the
    client that touches license files."""
    for p in (license_path(), session_path()):
        if p.exists():
            try:
                p.unlink()
            except OSError as e:
                print(f"[licensing] could not remove {p}: {e}")


# ── HTTP helpers ────────────────────────────────────────────────────────

def _get_json(path: str, *, bearer: str | None = None,
              timeout: float = 10.0) -> tuple[bool, dict | str]:
    """GET twin of `_post_json` — used by `fetch_account_state()` so the
    Account pane can show all registered devices, not just this one."""
    url = f"{LICENSE_SERVER_URL.rstrip('/')}{path}"
    headers = {}
    if bearer:
        headers["Authorization"] = f"Bearer {bearer}"
    req = urllib.request.Request(url, headers=headers, method="GET")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return True, json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        try:
            err = json.loads(e.read().decode("utf-8"))
            return False, err.get("detail") or err.get("error") or f"server returned {e.code}"
        except Exception:
            return False, f"server error ({e.code})"
    except urllib.error.URLError as e:
        return False, f"can't reach server: {e.reason}"
    except Exception as e:
        return False, f"unexpected error: {e}"


def fetch_account_state() -> tuple[bool, dict | str]:
    """Hit GET /me on the license server and return its full payload —
    {email, plan, subscription_valid_until, devices, device_cap, invoices}.
    Account pane uses this to render the device list. Returns (False, "")
    silently if the session is missing — caller will fall back to local
    license state (current device only)."""
    session = _load_session()
    if not session or not session.get("token"):
        return False, "not signed in"
    return _get_json("/me", bearer=session["token"])


def _post_json(path: str, body: dict, *, bearer: str | None = None,
               timeout: float = 10.0) -> tuple[bool, dict | str]:
    """Returns (ok, body) where body is a dict on success or an error string."""
    url = f"{LICENSE_SERVER_URL.rstrip('/')}{path}"
    data = json.dumps(body).encode("utf-8")
    headers = {"Content-Type": "application/json"}
    if bearer:
        headers["Authorization"] = f"Bearer {bearer}"
    req = urllib.request.Request(url, data=data, headers=headers, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return True, json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        try:
            err = json.loads(e.read().decode("utf-8"))
            return False, err.get("detail") or err.get("error") or f"server returned {e.code}"
        except Exception:
            return False, f"server error ({e.code})"
    except urllib.error.URLError as e:
        return False, f"can't reach server: {e.reason}"
    except Exception as e:
        return False, f"unexpected error: {e}"


# ── Public surface — used by bank.py UI ─────────────────────────────────

def request_magic_link(email: str) -> tuple[bool, str]:
    email = (email or "").strip().lower()
    if "@" not in email or "." not in email.split("@", 1)[-1]:
        return False, "Введи валидный email."
    ok, body = _post_json("/auth/request-link", {"email": email})
    if not ok:
        return False, body if isinstance(body, str) else "Не удалось отправить код."
    return True, f"Код отправлен на {email}. Проверь почту."


def activate_with_code(email: str, code: str) -> tuple[bool, str]:
    """Exchange the 6-char code for a session + signed device cert. On success
    the cert lands at license.json and load_license() picks up the new plan."""
    email = (email or "").strip().lower()
    code = (code or "").strip().upper()
    if not code or len(code) != 6:
        return False, "Код состоит из 6 символов."
    body = {
        "email": email,
        "code": code,
        "machine_id": machine_id(),
        "machine_name": machine_name(),
    }
    ok, resp = _post_json("/auth/verify-code", body)
    if not ok:
        return False, resp if isinstance(resp, str) else "Неверный или истёкший код."
    if not isinstance(resp, dict) or not resp.get("ok"):
        return False, "Сервер отклонил активацию."

    cert = resp.get("device_cert")
    session_token = resp.get("session_token")
    if not cert or not session_token:
        return False, "Сервер вернул неполный ответ."

    _save_session(session_token, email)
    state = _install_cert(cert)
    plan_label = {"free": "Free", "lite": "Pro Lite", "plus": "Pro Plus"}.get(state.plan, state.plan)
    return True, f"Активировано — {plan_label}."


def refresh_cert() -> tuple[bool, str]:
    """Try to fetch a fresh cert using the saved session token. Called at app
    startup if `LicenseState.needs_refresh` is True. Silent — no UI prompt."""
    session = _load_session()
    if not session or not session.get("token"):
        return False, "No saved session — user must log in again."
    body = {
        "machine_id": machine_id(),
        "machine_name": machine_name(),
    }
    ok, resp = _post_json("/devices/refresh", body, bearer=session["token"])
    if not ok:
        return False, resp if isinstance(resp, str) else "Refresh failed."
    if not isinstance(resp, dict) or not resp.get("ok"):
        return False, "Server rejected refresh."
    cert = resp.get("device_cert")
    if not cert:
        return False, "Server returned no cert."
    _install_cert(cert)
    return True, "Cert refreshed."


def current_account_email() -> str | None:
    """Email of the logged-in user, or None if no session. Used by the UI to
    show 'Logged in as ...' vs the email-entry form."""
    session = _load_session()
    if session and session.get("email"):
        return session["email"]
    state = load_license()
    return state.email


def cabinet_url() -> str:
    """URL the 'Manage account' button opens in the user's browser."""
    return f"{LICENSE_SERVER_URL.rstrip('/')}/cabinet"


def request_meeting_token() -> tuple[bool, str]:
    """Ask the license server for a short-lived signed token authorising
    a meeting capture session. Returns (True, token_string) on success or
    (False, human_reason) on failure.

    This is the runtime gate for the Meetings feature. The Swift sysaudio
    helper requires this token (verified against a hardcoded Ed25519
    public key) before it'll start any SCStream. If the server says no
    (no subscription / expired / wrong plan), the helper never starts,
    and patching the client to skip this call doesn't help — the helper
    itself enforces the signature check.

    The function does NOT cache the token. Each meeting start fetches a
    fresh one; the helper validates its `exp` claim against wall clock
    at the moment it spins up. Tokens are short-lived (4h server-side)
    so a cancelled subscription stops new recordings within hours."""
    session = _load_session()
    if not session or not session.get("token"):
        return False, "Не залогинен. Открой Account → Sign in."
    body = {"machine_id": machine_id()}
    ok, resp = _post_json("/meetings/issue-token", body, bearer=session["token"])
    if not ok:
        # _post_json's error message is already user-friendly. Pass through.
        return False, resp if isinstance(resp, str) else "Server rejected request."
    if not isinstance(resp, dict) or not resp.get("ok"):
        return False, "Сервер не вернул токен."
    token = resp.get("token")
    if not isinstance(token, str) or "." not in token:
        return False, "Получен невалидный токен."
    return True, token
