#!/usr/bin/env python3
"""
ExpressVPN for Teams licences from Snipe-IT → data/expressvpn.json for the IT dashboard.

Each Snipe-IT licence row = one Teams activation code (product key). GET /licenses/{id}/seats
returns every seat slot — assigned checkouts and empty slots (Available for assignment).

Env (same Snipe-IT app registration as fetch_snipe_it.py):
  SNIPE_URL, SNIPE_API_KEY
  EXPRESSVPN_PORTAL_URL            — default https://teams.expressvpn.com/dashboard
  EXPRESSVPN_SUBSCRIPTION_ID       — Teams subscription UUID (dashboard reference)
  EXPRESSVPN_SUBSCRIPTION_EXPIRES  — YYYY-MM-DD fallback when Snipe has no expiration_date (from Teams portal)
  EXPRESSVPN_EXPIRY_OVERRIDES      — optional JSON map of product_key or snipe id → YYYY-MM-DD
  EXPRESSVPN_INCLUDE_EXPIRED       — set 1 to include expired Snipe-IT licence rows
  EXPRESSVPN_EXPIRY_ALERT_DAYS     — dashboard Alerts when expiry is within N days (default 30)
  EXPRESSVPN_EXPIRY_ALERT_MAX      — max expiring rows in expiringAlertDetail (default 50; max 500)
  EXPRESSVPN_OUT                   — default data/expressvpn.json (or MariaDB feed snapshot)

Setup:
  1. Add ExpressVPN software licences in Snipe-IT (manufacturer ExpressVPN), seats=1 per row.
  2. python3 app/src/jobs/fetch_expressvpn.py
"""
from __future__ import annotations

import json
import os
import sys
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any

import requests

from store import write_json_snapshot

from env_config import env_csv, env_str
from paths import DATA_DIR, REPO_ROOT, load_env_file

ROOT = REPO_ROOT
OUT = Path(os.environ.get("EXPRESSVPN_OUT", str(DATA_DIR / "expressvpn.json")))


def load_env_file() -> None:
    path = REPO_ROOT / ".env"
    if not path.is_file():
        return
    for raw in path.read_text(encoding="utf-8-sig").splitlines():
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        if line.startswith("export "):
            line = line[7:].strip()
        if "=" not in line:
            continue
        key, _, val = line.partition("=")
        key = key.strip()
        val = val.strip().strip('"').strip("'")
        if not key:
            continue
        if val:
            os.environ[key] = val
            continue
        prev = os.environ.get(key)
        if prev is None or not str(prev).strip():
            os.environ[key] = val


def instance_web_url(api_base: str) -> str:
    base = api_base.rstrip("/")
    for suffix in ("/api/v1", "/api"):
        if base.lower().endswith(suffix):
            return base[: -len(suffix)] or base
    return base


def _license_rows(data: Any) -> list[dict[str, Any]]:
    if not isinstance(data, dict):
        return []
    rows = data.get("rows")
    if isinstance(rows, list):
        return [r for r in rows if isinstance(r, dict)]
    return []


def _expiration_iso(lic: dict[str, Any]) -> str:
    exp = lic.get("expiration_date")
    if isinstance(exp, dict):
        return str(exp.get("date") or "").strip()
    return ""


def _product_label(name: str) -> str:
    low = name.lower()
    if "dedicated ip" in low:
        return "Dedicated IP"
    return "ExpressVPN"


def _is_expressvpn_license(lic: dict[str, Any]) -> bool:
    names = {s.lower() for s in env_csv("EXPRESSVPN_SNIPE_MATCH")}
    mfr = lic.get("manufacturer")
    mfr_name = str(mfr.get("name") or "").strip().lower() if isinstance(mfr, dict) else ""
    if mfr_name in names:
        return True
    low_name = str(lic.get("name") or "").strip().lower()
    return low_name in names


def _license_is_active(lic: dict[str, Any], today: str, include_expired: bool) -> bool:
    if lic.get("deleted_at"):
        return False
    if include_expired:
        return True
    exp = _expiration_iso(lic)
    return not exp or exp >= today


def fetch_expressvpn_licenses(
    session: requests.Session, base: str, include_expired: bool
) -> tuple[list[dict[str, Any]], str | None]:
    today = date.today().isoformat()
    limit = 500
    offset = 0
    matched: list[dict[str, Any]] = []
    try:
        while True:
            r = session.get(
                f"{base}/licenses",
                params={"limit": limit, "offset": offset},
                timeout=120,
            )
            if not r.ok:
                safe = r.url.split("?", 1)[0] if getattr(r, "url", None) else f"{base}/licenses"
                return [], (
                    f"Snipe-IT GET {safe} → HTTP {r.status_code}. "
                    "Check SNIPE_URL and SNIPE_API_KEY in .env."
                )
            data = r.json()
            rows = _license_rows(data)
            for lic in rows:
                if not _is_expressvpn_license(lic):
                    continue
                if not _license_is_active(lic, today, include_expired):
                    continue
                matched.append(lic)
            if len(rows) < limit:
                break
            offset += len(rows)
    except requests.RequestException as ex:
        return [], f"Snipe-IT request failed: {ex}"

    matched.sort(
        key=lambda lic: (
            _product_label(str(lic.get("name") or "")),
            str(lic.get("product_key") or "").lower(),
        )
    )
    return matched, None


def fetch_license_seats(
    session: requests.Session, base: str, license_id: object
) -> tuple[list[dict[str, Any]], str | None]:
    try:
        r = session.get(f"{base}/licenses/{license_id}/seats", timeout=60)
    except requests.RequestException as ex:
        return [], f"seats request failed: {ex}"
    if not r.ok:
        return [], f"seats HTTP {r.status_code}"
    return _license_rows(r.json()), None


def _iter_license_seats(
    seats: list[dict[str, Any]],
) -> list[tuple[dict[str, Any], dict[str, Any] | None]]:
    """Every Snipe seat slot: (seat, assigned_user) or (seat, None) when empty."""
    out: list[tuple[dict[str, Any], dict[str, Any] | None]] = []
    for seat in sorted(seats, key=lambda row: int(row.get("id") or 0)):
        if seat.get("disabled"):
            continue
        user = seat.get("assigned_user")
        if isinstance(user, dict) and str(user.get("email") or "").strip():
            out.append((seat, user))
        else:
            out.append((seat, None))
    return out


def _snipe_seat_count(lic: dict[str, Any]) -> int:
    try:
        return max(int(lic.get("seats") or 1), 1)
    except (TypeError, ValueError):
        return 1


def _license_notes(lic: dict[str, Any], *, available: int) -> str:
    notes = str(lic.get("notes") or "").strip()
    if available > 0:
        seat_word = "seat" if available == 1 else "seats"
        extra = f"{available} empty {seat_word}"
        notes = f"{notes} · {extra}".strip(" · ") if notes else extra
    return notes


def _parse_positive_int(name: str, default: int, *, max_value: int | None = None) -> int:
    raw = os.environ.get(name, "").strip()
    if not raw:
        return default
    try:
        val = int(raw)
    except ValueError:
        return default
    if val < 0:
        return default
    if max_value is not None:
        return min(val, max_value)
    return val


def _normalize_expiry_date(raw: str) -> str:
    raw = str(raw or "").strip()
    if not raw:
        return ""
    try:
        return date.fromisoformat(raw[:10]).isoformat()
    except ValueError:
        return ""


def _load_expiry_overrides() -> dict[str, str]:
    """
    Optional per-activation-code dates from Teams portal.
    EXPRESSVPN_EXPIRY_OVERRIDES or data/expressvpn-expiry-overrides.json
    """
    raw_path = os.environ.get("EXPRESSVPN_EXPIRY_OVERRIDES", "").strip()
    path = Path(raw_path) if raw_path else DATA_DIR / "expressvpn-expiry-overrides.json"
    if not path.is_file():
        return {}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return {}
    if not isinstance(data, dict):
        return {}
    out: dict[str, str] = {}
    for key, value in data.items():
        norm = _normalize_expiry_date(str(value))
        if norm:
            out[str(key).strip().lower()] = norm
    return out


def _resolve_license_expires(
    lic: dict[str, Any],
    *,
    overrides: dict[str, str],
    subscription_expires: str,
) -> str:
    """Snipe expiration_date → per-code override JSON → subscription fallback."""
    exp = _expiration_iso(lic)
    if exp:
        return exp
    product_key = str(lic.get("product_key") or "").strip().lower()
    if product_key and product_key in overrides:
        return overrides[product_key]
    lic_id = str(lic.get("id") or "").strip().lower()
    if lic_id and lic_id in overrides:
        return overrides[lic_id]
    return subscription_expires


def _days_until_expiry(expires: str) -> int | None:
    if not expires:
        return None
    try:
        exp = date.fromisoformat(str(expires).strip()[:10])
    except ValueError:
        return None
    return (exp - date.today()).days


def _available_row(
    lic: dict[str, Any],
    *,
    seat: dict[str, Any] | None,
    resolved_expires: str,
    notes: str,
) -> dict[str, Any]:
    name = str(lic.get("name") or "").strip()
    product_key = str(lic.get("product_key") or "").strip()
    seat_id = seat.get("id") if isinstance(seat, dict) else None
    return {
        "snipeLicenseId": lic.get("id"),
        "seatId": seat_id,
        "name": name,
        "product": _product_label(name),
        "username": "",
        "email": "",
        "expires": resolved_expires or _expiration_iso(lic),
        "seats": 1,
        "freeSeats": 1,
        "status": "Available",
        "productKey": product_key,
        "notes": notes,
    }


def _assigned_row(
    lic: dict[str, Any],
    seat: dict[str, Any],
    user: dict[str, Any],
    *,
    resolved_expires: str,
    notes: str,
) -> dict[str, Any]:
    name = str(lic.get("name") or "").strip()
    product_key = str(lic.get("product_key") or "").strip()
    return {
        "snipeLicenseId": lic.get("id"),
        "seatId": seat.get("id"),
        "name": name,
        "product": _product_label(name),
        "username": str(user.get("name") or "").strip(),
        "email": str(user.get("email") or "").strip(),
        "expires": resolved_expires or _expiration_iso(lic),
        "seats": 1,
        "freeSeats": 0,
        "status": "Assigned",
        "productKey": product_key,
        "notes": notes,
    }


def _export_license_rows(
    lic: dict[str, Any],
    seats: list[dict[str, Any]],
    *,
    resolved_expires: str,
) -> list[dict[str, Any]]:
    """
    One dashboard row per Snipe seat slot: Assigned when checked out, Available when empty.
    """
    expires = resolved_expires or _expiration_iso(lic)
    seat_slots = _iter_license_seats(seats)
    if not seat_slots:
        notes = _license_notes(lic, available=_snipe_seat_count(lic))
        return [_available_row(lic, seat=None, resolved_expires=expires, notes=notes)]

    available_count = sum(1 for _, user in seat_slots if user is None)
    assigned_notes = str(lic.get("notes") or "").strip()
    available_notes = _license_notes(lic, available=available_count)

    rows: list[dict[str, Any]] = []
    for seat, user in seat_slots:
        if user is not None:
            rows.append(
                _assigned_row(
                    lic, seat, user, resolved_expires=expires, notes=assigned_notes
                )
            )
        else:
            rows.append(
                _available_row(
                    lic, seat=seat, resolved_expires=expires, notes=available_notes
                )
            )
    return rows


def main() -> int:
    load_env_file()
    base = os.environ.get("SNIPE_URL", "").strip().rstrip("/")
    token = os.environ.get("SNIPE_API_KEY", "").strip()
    portal_url = env_str("EXPRESSVPN_PORTAL_URL")
    subscription_id = env_str("EXPRESSVPN_SUBSCRIPTION_ID")
    subscription_expires = _normalize_expiry_date(
        os.environ.get("EXPRESSVPN_SUBSCRIPTION_EXPIRES", "")
    )
    expiry_overrides = _load_expiry_overrides()
    include_expired = os.environ.get("EXPRESSVPN_INCLUDE_EXPIRED", "").strip().lower() in (
        "1",
        "true",
        "yes",
    )

    if not base or not token:
        payload = {
            "updatedAt": datetime.now(timezone.utc).isoformat(),
            "portalUrl": portal_url,
            "subscriptionId": subscription_id,
            "source": "snipe-it",
            "licencesTotal": None,
            "licencesAssigned": None,
            "licencesAvailable": None,
            "licenses": [],
            "assignees": [],
            "expressVpnError": "Missing SNIPE_URL or SNIPE_API_KEY in .env (see env.example).",
        }
        out_path = write_json_snapshot(OUT, payload)
        print(payload["expressVpnError"], file=sys.stderr)
        return 1

    session = requests.Session()
    session.headers.update(
        {"Authorization": f"Bearer {token}", "Accept": "application/json"}
    )

    raw_licenses, err = fetch_expressvpn_licenses(session, base, include_expired)
    seat_errs: list[str] = []
    expiry_alert_days = _parse_positive_int("EXPRESSVPN_EXPIRY_ALERT_DAYS", 30)
    expiry_alert_max = _parse_positive_int("EXPRESSVPN_EXPIRY_ALERT_MAX", 50, max_value=500)

    licenses: list[dict[str, Any]] = []
    for lic in raw_licenses:
        seats, seat_err = fetch_license_seats(session, base, lic.get("id"))
        if seat_err:
            seat_errs.append(f"license {lic.get('id')}: {seat_err}")
        resolved_expires = _resolve_license_expires(
            lic,
            overrides=expiry_overrides,
            subscription_expires=subscription_expires,
        )
        for row in _export_license_rows(lic, seats, resolved_expires=resolved_expires):
            if row.get("status") == "Assigned":
                days = _days_until_expiry(str(row.get("expires") or ""))
                if days is not None:
                    row["daysUntilExpiry"] = days
            licenses.append(row)

    if seat_errs and not err:
        err = "; ".join(seat_errs[:3])
        if len(seat_errs) > 3:
            err += f" (+{len(seat_errs) - 3} more)"

    expiring_all = [
        row
        for row in licenses
        if row.get("status") == "Assigned"
        and row.get("daysUntilExpiry") is not None
        and int(row["daysUntilExpiry"]) <= expiry_alert_days
    ]
    expiring_all.sort(
        key=lambda row: (int(row.get("daysUntilExpiry") or 0), str(row.get("email") or "").lower())
    )

    assignees = [
        {
            "username": row["username"],
            "email": row["email"],
            "product": row["product"],
            "expires": row["expires"],
            "status": row["status"],
            "snipeLicenseId": row["snipeLicenseId"],
            "notes": row["notes"],
        }
        for row in licenses
        if row.get("email")
    ]

    activation_codes_total = len(raw_licenses)
    total_licences = len(licenses)
    assigned_licences = sum(1 for row in licenses if row.get("status") == "Assigned")
    available_licences = sum(1 for row in licenses if row.get("status") == "Available")

    by_product_key: dict[str, list[str]] = {}
    for row in licenses:
        if row.get("status") != "Assigned":
            continue
        pk = str(row.get("productKey") or "").strip()
        if not pk:
            continue
        label = str(row.get("email") or row.get("username") or "").strip()
        by_product_key.setdefault(pk, []).append(label)
    shared_product_keys = {pk: users for pk, users in by_product_key.items() if len(users) > 1}
    shared_max_users = max((len(users) for users in shared_product_keys.values()), default=0)

    payload: dict[str, Any] = {
        "updatedAt": datetime.now(timezone.utc).isoformat(),
        "portalUrl": portal_url,
        "subscriptionId": subscription_id,
        "subscriptionExpires": subscription_expires or None,
        "source": "snipe-it",
        "snipeInstanceUrl": instance_web_url(base),
        "activationCodesTotal": activation_codes_total,
        "licencesTotal": total_licences,
        "licencesAssigned": assigned_licences,
        "licencesAvailable": available_licences,
        "sharedProductKeysTotal": len(shared_product_keys),
        "sharedProductKeyMaxUsers": shared_max_users,
        "licenses": licenses,
        "assignees": assignees,
        "expiryAlertDays": expiry_alert_days,
        "expiringAlertTotal": len(expiring_all),
        "expiringAlertTruncated": len(expiring_all) > expiry_alert_max,
        "expiringAlertDetail": expiring_all[:expiry_alert_max],
        "expressVpnError": err,
    }

    out_path = write_json_snapshot(OUT, payload)
    print(
        f"Wrote {out_path} ({assigned_licences} assigned, "
        f"{available_licences} available seats, {activation_codes_total} activation codes, "
        f"{total_licences} rows)"
    )
    if err:
        print(f"Note: {err}", file=sys.stderr)
    return 0 if not err else 1


if __name__ == "__main__":
    raise SystemExit(main())
