#!/usr/bin/env python3
"""
Pull aggregate counts, **deployed asset rows**, **per-location deployed + in stock counts**, and
**hardwareByLocation** (device detail per location for CSV, including **assigned to** user/location when
the API returns `assigned_to`) from the Snipe-IT JSON API → data/snipe-it.json.
Also records **inStockAssignedUser** (assets with status In stock but still assigned / checked out
to a user) for dashboard Alerts — full tenant scan, same pass as location hardware.
Treats **assigned_type** values like ``App\\Models\\User`` as user (not only the literal ``user``).

Setup:
  1. In Snipe-IT: your avatar → API → generate a personal access token (or legacy API key if enabled).
  2. cp env.example .env   # add SNIPE_URL and SNIPE_API_KEY (do not commit .env)
  3. python3 app/src/jobs/fetch_snipe_it.py

SNIPE_URL should include the API prefix, e.g. https://your-company.snipe-it.io/api/v1

macOS / Windows tiles (paginated GET /hardware):
  - Only assets whose status is **Deployed** or **In stock** (case-insensitive; matches status_label or nested status name).
  - macosDevices: Apple manufacturer and model/asset name does not look like iPhone/iPad/etc.
  - windowsDevices: non-Mac **Laptop** or **Desktop** category only (excludes monitors, APs, TVs, etc.).
    Override categories via env ``SNIPE_WINDOWS_DEVICE_CATEGORIES`` (comma-separated, default ``laptop,desktop``).
"""
from __future__ import annotations

from typing import Any

import json
import os
import re
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path

import requests

from store import write_json_snapshot

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

ROOT = REPO_ROOT
OUT = Path(os.environ.get("SNIPE_OUT", str(DATA_DIR / "snipe-it.json")))

# Apple mobile / wearables — excluded from “macOS devices”.
_APPLE_MOBILE = re.compile(
    r"iphone|ipad|ipod|apple\s*watch|watch\s*series|airpods|homepod",
    re.IGNORECASE,
)


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
        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 fetch_total(session: requests.Session, url: str) -> int | None:
    try:
        r = session.get(url, params={"limit": 1}, timeout=45)
    except requests.RequestException as ex:
        print(f"[warn] Snipe-IT: {ex}", file=sys.stderr)
        return None
    if not r.ok:
        safe = r.url.split("?", 1)[0] if getattr(r, "url", None) else url
        print(
            f"[warn] Snipe-IT GET {safe} → HTTP {r.status_code} {getattr(r, 'reason', '') or ''}. "
            "Check SNIPE_URL (must end with /api/v1), SNIPE_API_KEY, and token scope in Snipe-IT → your avatar → API.",
            file=sys.stderr,
        )
        return None
    try:
        data = r.json()
    except ValueError:
        return None
    if isinstance(data, dict) and "total" in data:
        try:
            return int(data["total"])
        except (TypeError, ValueError):
            return None
    return None


def _manufacturer_name(asset: dict) -> str:
    m = asset.get("manufacturer")
    if isinstance(m, dict) and m.get("name"):
        return str(m["name"]).strip().lower()
    model = asset.get("model")
    if isinstance(model, dict):
        m2 = model.get("manufacturer")
        if isinstance(m2, dict) and m2.get("name"):
            return str(m2["name"]).strip().lower()
    return ""


def _model_name(asset: dict) -> str:
    model = asset.get("model")
    if isinstance(model, dict) and model.get("name"):
        return str(model["name"]).strip()
    return ""


def _category_name(asset: dict) -> str:
    cat = asset.get("category")
    if isinstance(cat, dict):
        return str(cat.get("name") or "").strip().lower()
    return ""


def _windows_device_categories() -> frozenset[str]:
    raw = (os.environ.get("SNIPE_WINDOWS_DEVICE_CATEGORIES") or "laptop,desktop").strip()
    parts = {p.strip().lower() for p in raw.split(",") if p.strip()}
    return frozenset(parts) or frozenset({"laptop", "desktop"})


def _is_macos_hardware(asset: dict) -> bool:
    if _manufacturer_name(asset) != "apple":
        return False
    blob = f"{_model_name(asset)} {asset.get('name') or ''} {asset.get('asset_tag') or ''}"
    return not _APPLE_MOBILE.search(blob)


def _is_windows_hardware(asset: dict) -> bool:
    """Non-Mac laptop/desktop computers — not monitors, networking, AV, etc."""
    if _is_macos_hardware(asset):
        return False
    return _category_name(asset) in _windows_device_categories()


def _hardware_rows(payload: dict) -> list:
    for key in ("rows", "hardware", "assets"):
        v = payload.get(key)
        if isinstance(v, list):
            return v
    return []


def _hardware_status_normalized(asset: dict) -> str:
    lab = asset.get("status_label")
    if isinstance(lab, str) and lab.strip():
        return lab.strip().lower()
    st = asset.get("status")
    if isinstance(st, dict):
        return str(st.get("name") or st.get("label") or "").strip().lower()
    return ""


def _status_label_deployed(asset: dict) -> bool:
    return _hardware_status_normalized(asset) == env_str("SNIPE_STATUS_DEPLOYED").lower()


def _status_deployed_or_in_stock(asset: dict) -> bool:
    n = _hardware_status_normalized(asset)
    deployed = env_str("SNIPE_STATUS_DEPLOYED").lower()
    in_stock = env_str("SNIPE_STATUS_IN_STOCK").lower()
    return n == deployed or n == in_stock


def _location_from_object(obj: object) -> tuple[int | None, str]:
    if not isinstance(obj, dict):
        return None, "—"
    loc_id: int | None = None
    raw_id = obj.get("id")
    if raw_id is not None:
        try:
            loc_id = int(raw_id)
        except (TypeError, ValueError):
            loc_id = None
    loc_name = str(obj.get("name") or "").strip() or "—"
    return loc_id, loc_name


def _location_tuple_from_asset(asset: dict) -> tuple[int | None, str]:
    """
    Snipe GET /hardware: ``location`` is the asset's site; when empty the admin UI
    often still shows ``rtd_location`` (default / ready-to-deploy location).
    """
    loc = asset.get("location")
    if isinstance(loc, dict):
        loc_id, loc_name = _location_from_object(loc)
        if loc_id is not None or loc_name != "—":
            return loc_id, loc_name
    if asset.get("location_id") is not None:
        try:
            return int(asset["location_id"]), "—"
        except (TypeError, ValueError):
            pass
    rtd = asset.get("rtd_location")
    if isinstance(rtd, dict):
        loc_id, loc_name = _location_from_object(rtd)
        if loc_id is not None or loc_name != "—":
            return loc_id, loc_name
    return None, "—"


def _assigned_type_raw_lower(asset: dict) -> str:
    return str(asset.get("assigned_type") or "").strip().lower()


def _assigned_type_is_user(asset: dict) -> bool:
    """Polymorphic assigned_type may be ``user`` or a class name containing ``user`` (e.g. App\\Models\\User)."""
    t = _assigned_type_raw_lower(asset)
    if not t:
        return False
    if "location" in t:
        return False
    if "asset" in t and "user" not in t:
        return False
    return "user" in t


def _assigned_type_is_location(asset: dict) -> bool:
    t = _assigned_type_raw_lower(asset)
    return bool(t) and "location" in t


def _assigned_to_user_without_explicit_type(asset: dict) -> bool:
    """Some payloads omit assigned_type but expand assigned_to as a user, or set assigned_user id."""
    if _assigned_type_raw_lower(asset):
        return False
    raw = asset.get("assigned_to")
    if isinstance(raw, dict) and raw.get("id") is not None:
        email = str(raw.get("email") or "").strip()
        if "@" in email:
            return True
        if raw.get("first_name") or raw.get("last_name"):
            return True
        if str(raw.get("username") or "").strip():
            return True
    au = asset.get("assigned_user")
    if au is None:
        return False
    s = str(au).strip().lower()
    if not s or s in ("0", "none"):
        return False
    try:
        return int(float(s)) > 0
    except (TypeError, ValueError):
        return False


def _asset_assigned_to_user(asset: dict) -> bool:
    return _assigned_type_is_user(asset) or _assigned_to_user_without_explicit_type(asset)


def _in_stock_but_assigned_to_user(asset: dict) -> bool:
    """Status label In stock while checkout/assignment targets a person (data inconsistency Snipe allows)."""
    if _hardware_status_normalized(asset) != "in stock":
        return False
    if not _asset_assigned_to_user(asset):
        return False
    af = _assigned_fields(asset)
    if str(af.get("assignedToEmail") or "").strip() or str(af.get("assignedToName") or "").strip():
        return True
    raw = asset.get("assigned_to")
    if isinstance(raw, dict) and raw.get("id") is not None:
        return True
    return bool(asset.get("assigned_user"))


def _assigned_fields(asset: dict) -> dict[str, Any]:
    """
    Snipe-IT GET /hardware: assigned_to is usually an expanded user (or location/asset) object;
    assigned_type is typically 'user', 'location', or 'asset' (API may return full class names).
    """
    raw = asset.get("assigned_to")
    typ = str(asset.get("assigned_type") or "").strip().lower()
    if _assigned_type_is_user(asset):
        typ = "user"
    elif _assigned_type_is_location(asset):
        typ = "location"
    elif typ and "asset" in typ and "user" not in typ:
        typ = "asset"
    empty: dict[str, Any] = {
        "assignedToType": "",
        "assignedToId": None,
        "assignedToName": "",
        "assignedToEmail": "",
    }
    if raw is None:
        return empty
    if not isinstance(raw, dict):
        return empty
    rid = raw.get("id")
    try:
        assigned_id = int(rid) if rid is not None else None
    except (TypeError, ValueError):
        assigned_id = None
    email = str(raw.get("email") or "").strip()
    name = str(raw.get("name") or "").strip()
    if not name:
        fn = str(raw.get("first_name") or "").strip()
        ln = str(raw.get("last_name") or "").strip()
        name = (fn + " " + ln).strip()
    if not name:
        name = str(raw.get("username") or "").strip()
    if not name and typ == "location":
        name = str(raw.get("city") or raw.get("name") or "").strip()
    if not name and typ == "asset":
        name = str(raw.get("asset_tag") or raw.get("name") or "").strip()
    return {
        "assignedToType": typ,
        "assignedToId": assigned_id,
        "assignedToName": name,
        "assignedToEmail": email,
    }


def _hardware_export_row(asset: dict) -> dict[str, Any]:
    loc_id, loc_name = _location_tuple_from_asset(asset)
    model = _model_name(asset)
    sl = asset.get("status_label")
    if not isinstance(sl, str):
        st = asset.get("status")
        if isinstance(st, dict):
            sl = str(st.get("name") or st.get("label") or "")
        else:
            sl = str(sl or "")
    row: dict[str, Any] = {
        "locationId": loc_id,
        "locationName": loc_name,
        "assetId": asset.get("id"),
        "assetTag": str(asset.get("asset_tag") or "").strip(),
        "name": str(asset.get("name") or "").strip(),
        "model": model,
        "serial": str(asset.get("serial") or "").strip(),
        "statusLabel": str(sl).strip(),
    }
    cat = _category_name(asset)
    if cat:
        row["category"] = cat
    if _status_deployed_or_in_stock(asset):
        row["isMacos"] = _is_macos_hardware(asset)
        row["isWindows"] = _is_windows_hardware(asset)
    row.update(_assigned_fields(asset))
    return row


def _user_export_row(user: dict) -> dict[str, Any]:
    fn = str(user.get("first_name") or "").strip()
    ln = str(user.get("last_name") or "").strip()
    name = (fn + " " + ln).strip() or str(user.get("name") or "").strip()
    return {
        "userId": user.get("id"),
        "username": str(user.get("username") or "").strip(),
        "name": name,
        "email": str(user.get("email") or "").strip(),
        "department": str(user.get("department") or "").strip(),
        "jobTitle": str(user.get("jobtitle") or user.get("job_title") or "").strip(),
        "activated": user.get("activated"),
    }


def fetch_users_detail(session: requests.Session, base: str) -> list[dict[str, Any]]:
    """Paginate GET /users for dashboard click-to-view table."""
    max_rows = int(os.environ.get("SNIPE_USERS_LIST_MAX", "2000") or "2000")
    limit = 500
    offset = 0
    flat: list[dict[str, Any]] = []
    try:
        while True:
            r = session.get(
                f"{base}/users",
                params={"limit": str(limit), "offset": str(offset)},
                timeout=120,
            )
            r.raise_for_status()
            data = r.json()
            if not isinstance(data, dict):
                break
            rows = data.get("rows")
            if not isinstance(rows, list):
                break
            for row in rows:
                if not isinstance(row, dict):
                    continue
                if row.get("deleted"):
                    continue
                if len(flat) >= max_rows:
                    break
                flat.append(_user_export_row(row))
            if len(flat) >= max_rows:
                print(
                    f"[info] snipe usersDetail capped at {max_rows} (SNIPE_USERS_LIST_MAX).",
                    file=sys.stderr,
                )
                break
            if len(rows) < limit:
                break
            offset += len(rows)
    except (requests.RequestException, ValueError, TypeError) as ex:
        print(f"[warn] users list: {ex}", file=sys.stderr)
        return []

    flat.sort(
        key=lambda x: (
            str(x.get("name") or "").lower(),
            str(x.get("email") or "").lower(),
        )
    )
    return flat


def fetch_deployed_devices(session: requests.Session, base: str) -> list[dict[str, Any]]:
    """
    Paginate hardware; return flat deployed rows for CSV / detail.

    Uses GET .../hardware?status=Deployed when accepted; otherwise scans all hardware and filters
    rows whose status label is Deployed.
    """
    max_rows = int(os.environ.get("SNIPE_DEPLOYED_LIST_MAX", "8000") or "8000")
    limit = 500
    offset = 0
    use_deployed_param = True
    first = session.get(
        f"{base}/hardware",
        params={"limit": "1", "offset": "0", "status": "Deployed"},
        timeout=60,
    )
    if not first.ok:
        use_deployed_param = False

    flat: list[dict[str, Any]] = []
    try:
        while True:
            params: dict[str, str | int] = {
                "limit": str(limit),
                "offset": str(offset),
            }
            if use_deployed_param:
                params["status"] = "Deployed"
            r = session.get(f"{base}/hardware", params=params, timeout=120)
            r.raise_for_status()
            data = r.json()
            if not isinstance(data, dict):
                break
            rows = _hardware_rows(data)
            for row in rows:
                if not isinstance(row, dict):
                    continue
                if row.get("deleted"):
                    continue
                if not _status_label_deployed(row):
                    continue
                if len(flat) >= max_rows:
                    break
                flat.append(_hardware_export_row(row))
            if len(flat) >= max_rows:
                print(
                    f"[info] snipe deployedDevices capped at {max_rows} (SNIPE_DEPLOYED_LIST_MAX).",
                    file=sys.stderr,
                )
                break
            if len(rows) < limit:
                break
            offset += len(rows)
    except (requests.RequestException, ValueError, TypeError) as ex:
        print(f"[warn] deployed hardware list: {ex}", file=sys.stderr)
        return []

    flat.sort(key=lambda x: (str(x.get("locationName") or "").lower(), str(x.get("assetTag") or "").lower()))
    return flat


def _empty_in_stock_user_alert() -> dict[str, Any]:
    return {"count": 0, "items": [], "itemsTruncated": False}


def fetch_location_hardware_report(
    session: requests.Session, base: str
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]:
    """
    Single full GET /hardware scan:
    - Per-location Deployed vs In stock counts (authoritative totals).
    - Per-location device rows (both statuses) for reports / hardwareByLocation; total rows
      capped by SNIPE_HARDWARE_BY_LOCATION_MAX (default 10000). locationDeviceCounts are always full.
    - inStockAssignedUser: every In stock asset assigned to a user (including App\\Models\\User).
      Sample rows capped by SNIPE_IN_STOCK_USER_ALERT_MAX; count is always the full total.
    """
    max_detail = int(
        os.environ.get(
            "SNIPE_HARDWARE_BY_LOCATION_MAX",
            os.environ.get("SNIPE_DEPLOYED_LIST_MAX", "10000"),
        )
        or "10000"
    )
    try:
        raw_alert = int(os.environ.get("SNIPE_IN_STOCK_USER_ALERT_MAX", "150") or "150")
    except ValueError:
        raw_alert = 150
    max_stock_user_alert_rows = max(1, min(raw_alert, 500))
    limit = 500
    offset = 0
    deployed: dict[tuple[int | None, str], int] = defaultdict(int)
    in_stock: dict[tuple[int | None, str], int] = defaultdict(int)
    devices_by_key: dict[tuple[int | None, str], list[dict[str, Any]]] = defaultdict(list)
    detail_rows = 0
    capped = False
    in_stock_user_samples: list[dict[str, Any]] = []
    in_stock_user_count = 0
    in_stock_user_samples_truncated = False
    try:
        while True:
            r = session.get(
                f"{base}/hardware",
                params={"limit": str(limit), "offset": str(offset)},
                timeout=120,
            )
            r.raise_for_status()
            data = r.json()
            if not isinstance(data, dict):
                return [], [], _empty_in_stock_user_alert()
            rows = _hardware_rows(data)
            for row in rows:
                if not isinstance(row, dict):
                    continue
                if row.get("deleted"):
                    continue
                key = _location_tuple_from_asset(row)
                n = _hardware_status_normalized(row)
                if n == "deployed":
                    deployed[key] += 1
                    if detail_rows < max_detail:
                        devices_by_key[key].append(_hardware_export_row(row))
                        detail_rows += 1
                    else:
                        capped = True
                elif n == "in stock":
                    in_stock[key] += 1
                    if _in_stock_but_assigned_to_user(row):
                        in_stock_user_count += 1
                        if len(in_stock_user_samples) < max_stock_user_alert_rows:
                            in_stock_user_samples.append(_hardware_export_row(row))
                        else:
                            in_stock_user_samples_truncated = True
                    if detail_rows < max_detail:
                        devices_by_key[key].append(_hardware_export_row(row))
                        detail_rows += 1
                    else:
                        capped = True
            if len(rows) < limit:
                break
            offset += len(rows)
    except (requests.RequestException, ValueError, TypeError):
        return [], [], _empty_in_stock_user_alert()

    if capped:
        print(
            f"[info] hardwareByLocation device rows capped at {max_detail} "
            "(SNIPE_HARDWARE_BY_LOCATION_MAX or SNIPE_DEPLOYED_LIST_MAX). "
            "locationDeviceCounts still reflect full inventory.",
            file=sys.stderr,
        )

    keys = set(deployed) | set(in_stock)
    summary: list[dict[str, Any]] = []
    by_location: list[dict[str, Any]] = []
    for key in sorted(keys, key=lambda k: (k[1].lower(), k[0] or 0)):
        lid, lname = key
        summary.append(
            {
                "locationId": lid,
                "locationName": lname,
                "deployed": int(deployed.get(key, 0)),
                "inStock": int(in_stock.get(key, 0)),
            }
        )
        devs = devices_by_key.get(key, [])
        devs.sort(
            key=lambda x: (
                str(x.get("statusLabel") or "").lower(),
                str(x.get("assetTag") or "").lower(),
            )
        )
        by_location.append(
            {
                "locationId": lid,
                "locationName": lname,
                "deployed": int(deployed.get(key, 0)),
                "inStock": int(in_stock.get(key, 0)),
                "devices": devs,
            }
        )
    stock_user_alert: dict[str, Any] = {
        "count": int(in_stock_user_count),
        "items": in_stock_user_samples,
        "itemsTruncated": bool(in_stock_user_samples_truncated),
    }
    return summary, by_location, stock_user_alert


def fetch_macos_windows_counts(session: requests.Session, base: str) -> tuple[int | None, int | None]:
    """
    Walk all /hardware pages. Returns (macosDevices, windowsDevices): non-deleted hardware whose
    status is Deployed or In stock — Mac laptops/desktops vs non-Mac laptop/desktop categories.
    """
    limit = 500
    offset = 0
    mac = 0
    win = 0
    try:
        while True:
            r = session.get(
                f"{base}/hardware",
                params={"limit": str(limit), "offset": str(offset)},
                timeout=120,
            )
            r.raise_for_status()
            data = r.json()
            if not isinstance(data, dict):
                return None, None
            rows = _hardware_rows(data)
            for row in rows:
                if not isinstance(row, dict):
                    continue
                if row.get("deleted"):
                    continue
                if not _status_deployed_or_in_stock(row):
                    continue
                if _is_macos_hardware(row):
                    mac += 1
                elif _is_windows_hardware(row):
                    win += 1
            if len(rows) < limit:
                break
            offset += len(rows)
    except (requests.RequestException, ValueError, TypeError):
        return None, None
    return mac, win


def main() -> int:
    load_env_file()
    base = os.environ.get("SNIPE_URL", "").strip().rstrip("/")
    token = os.environ.get("SNIPE_API_KEY", "").strip()
    if not base or not token:
        print(
            "Missing SNIPE_URL or SNIPE_API_KEY in environment (.env). See env.example.",
            file=sys.stderr,
        )
        return 1

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

    endpoints = {
        "hardware": "Assets (hardware)",
        "users": "Users",
    }

    out: dict = {
        "updatedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "instanceUrl": instance_web_url(base),
        "macosDevices": None,
        "windowsDevices": None,
    }
    for key in endpoints:
        total = fetch_total(session, f"{base}/{key}")
        out[key] = total if total is not None else None

    mac, win = fetch_macos_windows_counts(session, base)
    out["macosDevices"] = mac
    out["windowsDevices"] = win

    deployed = fetch_deployed_devices(session, base)
    if deployed:
        out["deployedDevices"] = deployed
    loc_status, hw_by_location, in_stock_user_alert = fetch_location_hardware_report(
        session, base
    )
    if loc_status:
        out["locationDeviceCounts"] = loc_status
    if hw_by_location:
        out["hardwareByLocation"] = hw_by_location
    if in_stock_user_alert.get("count"):
        out["inStockAssignedUser"] = in_stock_user_alert

    users_detail = fetch_users_detail(session, base)
    if users_detail:
        out["usersDetail"] = users_detail

    if out.get("hardware") is None and out.get("users") is None:
        print(
            "[warn] Snipe-IT snapshot has no hardware or user totals; dashboard Snipe-IT tiles stay empty until access is fixed. "
            "See HTTP errors above, then re-run this script.",
            file=sys.stderr,
        )

    out_path = write_json_snapshot(OUT, out)
    print(f"Wrote {out_path}")
    print(f"Wrote {OUT}")
    return 0


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