#!/usr/bin/env python3
"""MSP360 Managed Backup Service (MBS) CLI.

Read the fleet backup state and manage users / companies / licenses via the MBS
provider API. Reads run freely; every WRITE requires --confirm (a dry-run preview is
printed without it). See SKILL.md for the trigger list and examples.

  Reads:  status | monitoring | companies | users | computers | licenses | billing
  Writes: create-company | delete-company | create-user | delete-user
          grant-license | release-license | revoke-license | raw
"""
from __future__ import annotations

import argparse
import datetime as _dt
import json
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from msp360_client import Msp360Client, Msp360Error, STATUS_TEXT, BAD_STATUS  # noqa: E402
import backup_common as bc  # noqa: E402  (msp360_client put .claude/scripts on sys.path)


# --- helpers ------------------------------------------------------------------
def _root() -> Path:
    # honor CLAUDETOOLS_ROOT / identity.json, same as the vault/HTTP plumbing.
    return bc.resolve_root()


def _as_list(data, what: str) -> list:
    """A read endpoint promised a JSON array; a non-list 2xx body is anomalous."""
    if isinstance(data, list):
        return data
    raise Msp360Error(f"expected a list from {what}, got {type(data).__name__}: {str(data)[:160]}")


def log_error(brief: str, context: str = "") -> None:
    """Best-effort fleet error log; never breaks the caller (per CLAUDE.md)."""
    try:
        script = _root() / ".claude" / "scripts" / "log-skill-error.sh"
        if not script.exists():
            return
        cmd = ["bash", str(script), "msp360", brief]
        if context:
            cmd += ["--context", context]
        subprocess.run(cmd, capture_output=True, text=True, timeout=20)
    except Exception:
        pass


def out(obj, as_json: bool) -> None:
    if as_json:
        print(json.dumps(obj, indent=2, default=str))


def human(n) -> str:
    try:
        n = float(n)
    except (TypeError, ValueError):
        return str(n)
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if abs(n) < 1024:
            return f"{n:.1f}{unit}"
        n /= 1024
    return f"{n:.1f}PB"


def _days_since(ts: str):
    """Whole days since an ISO timestamp. None if empty OR unparseable (callers must
    distinguish 'never ran' from 'could not parse' via the raw value themselves)."""
    if not ts:
        return None
    try:
        dt = _dt.datetime.fromisoformat(ts.strip().replace("Z", "+00:00"))
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=_dt.timezone.utc)
        return (_dt.datetime.now(_dt.timezone.utc) - dt).days
    except Exception:
        return None


def require_confirm(args, action: str) -> bool:
    """Return True to proceed; if --confirm absent, print the dry-run and return False."""
    if getattr(args, "confirm", False):
        return True
    print(f"[DRY-RUN] Would: {action}")
    print("[DRY-RUN] Re-run with --confirm to execute. Nothing was changed.")
    return False


# --- read commands ------------------------------------------------------------
def cmd_status(c: Msp360Client, args) -> int:
    comps = c.companies()
    users = c.users()
    mon = c.monitoring()
    if args.json:
        out({"companies": len(comps), "users": len(users), "plans": len(mon)}, True)
        return 0
    bad = sum(1 for m in mon if m.get("Status") in BAD_STATUS)
    print(f"[OK] MSP360 MBS reachable at {c.base}")
    print(f"[INFO] companies={len(comps)}  users={len(users)}  backup plans={len(mon)}  "
          f"plans needing attention={bad}")
    return 0


def cmd_monitoring(c: Msp360Client, args) -> int:
    mon = _as_list(c.monitoring(), "monitoring")
    rows = []
    for m in mon:
        if args.company and args.company.lower() not in (m.get("CompanyName") or "").lower():
            continue
        status = m.get("Status")
        last_raw = m.get("LastStart") or ""
        never = not last_raw
        age = _days_since(last_raw)
        # --failed = triage: anything that is NOT a clean completed run and is not
        # in-progress. Includes never-run, Unknown(5) and null status (dead plans).
        if args.failed:
            clean_success = status in (0, 1) and not never
            if clean_success or status == 4:  # 4 = Running
                continue
        # --stale = last run older than N days, OR never ran. An unparseable-but-present
        # timestamp is NOT claimed stale (avoid a false "not backing up").
        if args.stale is not None:
            if never:
                pass
            elif age is not None and age >= args.stale:
                pass
            else:
                continue
        rows.append((m, age))
    if args.json:
        out([r[0] for r in rows], True)
        return 0
    if not rows:
        print("[INFO] no plans match the filter.")
        return 0
    print(f"{'COMPANY':<26} {'COMPUTER':<20} {'PLAN':<26} {'STATUS':<12} {'LAST START':<19} DATA")
    for m, age in sorted(rows, key=lambda r: ((r[0].get('CompanyName') or '').lower(),
                                              r[0].get('ComputerName') or '')):
        st = STATUS_TEXT.get(m.get("Status"), str(m.get("Status")))
        last = m.get("LastStart") or "never"
        agestr = f" ({age}d)" if age is not None and age >= 30 else ""
        print(f"{(m.get('CompanyName') or '')[:25]:<26} {(m.get('ComputerName') or '')[:19]:<20} "
              f"{(m.get('PlanName') or '')[:25]:<26} {st:<12} {last[:19]:<19}{agestr} "
              f"{human(m.get('DataCopied', 0))}")
    print(f"\n[INFO] {len(rows)} plan(s). Filters: "
          f"company={args.company or '-'} failed={args.failed} stale>={args.stale}")
    return 0


def cmd_companies(c: Msp360Client, args) -> int:
    comps = _as_list(c.companies(), "companies")
    if args.json:
        out(comps, True)
        return 0
    for co in sorted(comps, key=lambda x: (x.get("Name") or "").lower()):
        lim = co.get("StorageLimit")
        limstr = "unlimited" if lim in (-1, None) else human(lim)
        print(f"  {(co.get('Name') or ''):<34} id={co.get('Id')}  storage_limit={limstr}")
    print(f"\n[INFO] {len(comps)} companies")
    return 0


def cmd_users(c: Msp360Client, args) -> int:
    users = _as_list(c.users(), "users")
    if args.company:
        users = [u for u in users if args.company.lower() in (u.get("Company") or "").lower()]
    if args.json:
        out(users, True)
        return 0
    for u in sorted(users, key=lambda x: (x.get("Company") or "", x.get("Email") or "")):
        en = "enabled" if u.get("Enabled") else "DISABLED"
        print(f"  {(u.get('Email') or ''):<34} {(u.get('Company') or ''):<26} "
              f"{en:<9} used={human(u.get('SpaceUsed', 0))} id={u.get('ID')}")
    print(f"\n[INFO] {len(users)} users" + (f" in '{args.company}'" if args.company else ""))
    return 0


def cmd_computers(c: Msp360Client, args) -> int:
    try:
        comps = _as_list(c.computers(), "computers")
    except Msp360Error as exc:
        msg = str(exc).lower()
        if "remote management" in msg or "not enabled" in msg or "not available" in msg:
            print("[INFO] /api/Computers is gated behind the Remote Management API feature, "
                  "which is not enabled on this MSP360 account. Use `users` + `monitoring` "
                  "for endpoint/backup data instead.")
            return 0
        raise
    if args.json:
        out(comps, True)
        return 0
    for m in comps:
        print(f"  {(m.get('Name') or m.get('ComputerName') or ''):<24} "
              f"user={m.get('User') or m.get('UserEmail') or ''} hid={m.get('Hid') or m.get('ID')}")
    print(f"\n[INFO] {len(comps) if isinstance(comps, list) else 0} computers")
    return 0


def cmd_licenses(c: Msp360Client, args) -> int:
    lic = _as_list(c.licenses(available=True if args.available else None), "licenses")
    if args.json:
        out(lic, True)
        return 0
    taken = sum(1 for x in lic if x.get("IsTaken"))
    for x in lic:
        state = "TAKEN" if x.get("IsTaken") else "free"
        num = x.get("Number")
        num = "" if num is None else num
        print(f"  #{str(num):<5} {(x.get('LicenseType') or ''):<22} {state:<6} "
              f"exp={(x.get('DateExpired') or '')[:10]} user={x.get('User') or '-'}")
    print(f"\n[INFO] {len(lic)} licenses ({taken} taken, {len(lic) - taken} free)")
    return 0


def cmd_billing(c: Msp360Client, args) -> int:
    out(c.billing(), True)  # billing has no table form; always JSON
    return 0


# --- write commands -----------------------------------------------------------
def cmd_create_company(c: Msp360Client, args) -> int:
    if not require_confirm(args, f"create company '{args.name}'"):
        return 0
    res = c.create_company(args.name)
    print(f"[OK] created company '{args.name}'")
    out(res, True)
    return 0


def cmd_delete_company(c: Msp360Client, args) -> int:
    print(f"[WARNING] Deleting company id={args.id} removes the company record.")
    if not require_confirm(args, f"DELETE company id={args.id}"):
        return 0
    res = c.delete_company(args.id)
    print(f"[OK] deleted company id={args.id}")
    out(res, True)
    return 0


def cmd_create_user(c: Msp360Client, args) -> int:
    payload = {
        "Email": args.email,
        "FirstName": args.first or "",
        "LastName": args.last or "",
        "Company": args.company,
        "Enabled": True,
        "NotificationEmails": [args.notify] if args.notify else [],
    }
    if args.password:
        payload["Password"] = args.password
    if not require_confirm(args, f"create user '{args.email}' in company '{args.company}'"):
        preview = dict(payload)
        if "Password" in preview:
            preview["Password"] = "***redacted***"  # never echo a secret to the transcript
        out(preview, True)
        return 0
    res = c.create_user(payload)
    print(f"[OK] created user '{args.email}'")
    out(res, True)
    return 0


def cmd_delete_user(c: Msp360Client, args) -> int:
    scope = "metadata only (backup data KEPT)" if args.keep_data else "metadata AND all backup data"
    print(f"[WARNING] Deleting user id={args.id}: {scope}.")
    if not require_confirm(args, f"DELETE user id={args.id} ({scope})"):
        return 0
    res = c.delete_user(args.id, keep_data=args.keep_data)
    print(f"[OK] deleted user id={args.id}")
    out(res, True)
    return 0


def cmd_grant_license(c: Msp360Client, args) -> int:
    payload = {"UserID": args.user_id}
    if args.license_id:
        payload["LicenseID"] = args.license_id
    if not require_confirm(args, f"grant license {args.license_id or '(auto)'} to user {args.user_id}"):
        out(payload, True)
        return 0
    res = c.grant_license(payload)
    print("[OK] license granted")
    out(res, True)
    return 0


def cmd_release_license(c: Msp360Client, args) -> int:
    payload = {"UserID": args.user_id}
    if args.license_id:
        payload["LicenseID"] = args.license_id
    if not require_confirm(args, f"release license from user {args.user_id}"):
        return 0
    res = c.release_license(payload)
    print("[OK] license released")
    out(res, True)
    return 0


def cmd_revoke_license(c: Msp360Client, args) -> int:
    payload = {"UserID": args.user_id}
    if args.license_id:
        payload["LicenseID"] = args.license_id
    if not require_confirm(args, f"revoke license from user {args.user_id}"):
        return 0
    res = c.revoke_license(payload)
    print("[OK] license revoked")
    out(res, True)
    return 0


def cmd_raw(c: Msp360Client, args) -> int:
    method = args.method.upper()
    body = json.loads(args.data) if args.data else None
    # Git-Bash rewrites a leading-slash arg into a Windows path (e.g.
    # '/api/Companies' -> 'C:/Program Files/Git/api/Companies'). Recover the /api/... tail.
    path = args.path
    if "/api/" in path and not path.startswith("/api"):
        path = path[path.index("/api/"):]
    elif not path.startswith("/"):
        path = "/" + path
    if method != "GET" and not require_confirm(args, f"{method} {path} {args.data or ''}"):
        return 0
    res = c.request(method, path, body=body)
    out(res, True)
    return 0


# --- argparse -----------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="msp360", description="MSP360 Managed Backup Service CLI")
    p.add_argument("--base", help="override API base URL")
    sub = p.add_subparsers(dest="command", required=True)

    def add_json(sp):
        sp.add_argument("--json", action="store_true", help="machine-readable JSON output")
        return sp

    add_json(sub.add_parser("status", help="reachability + fleet counts")).set_defaults(func=cmd_status)

    sp = sub.add_parser("monitoring", help="fleet backup status (the primary read)")
    sp.add_argument("--company", help="filter by company name substring")
    sp.add_argument("--failed", action="store_true", help="only plans not in a clean state")
    sp.add_argument("--stale", type=int, metavar="DAYS",
                    help="only plans whose last run is >= DAYS old (or never)")
    add_json(sp)
    sp.set_defaults(func=cmd_monitoring)

    add_json(sub.add_parser("companies", help="list companies")).set_defaults(func=cmd_companies)

    sp = sub.add_parser("users", help="list backup users")
    sp.add_argument("--company", help="filter by company name substring")
    add_json(sp)
    sp.set_defaults(func=cmd_users)

    add_json(sub.add_parser("computers", help="list managed endpoints")).set_defaults(func=cmd_computers)

    sp = sub.add_parser("licenses", help="list licenses")
    sp.add_argument("--available", action="store_true", help="only free/available licenses")
    add_json(sp)
    sp.set_defaults(func=cmd_licenses)

    sub.add_parser("billing", help="billing info (JSON)").set_defaults(func=cmd_billing)

    # writes
    sp = sub.add_parser("create-company", help="create a company [--confirm]")
    sp.add_argument("--name", required=True)
    sp.add_argument("--confirm", action="store_true")
    sp.set_defaults(func=cmd_create_company)

    sp = sub.add_parser("delete-company", help="delete a company by id [--confirm]")
    sp.add_argument("--id", required=True)
    sp.add_argument("--confirm", action="store_true")
    sp.set_defaults(func=cmd_delete_company)

    sp = sub.add_parser("create-user", help="create a backup user [--confirm]")
    sp.add_argument("--email", required=True)
    sp.add_argument("--company", required=True)
    sp.add_argument("--first")
    sp.add_argument("--last")
    sp.add_argument("--password")
    sp.add_argument("--notify", help="notification email")
    sp.add_argument("--confirm", action="store_true")
    sp.set_defaults(func=cmd_create_user)

    sp = sub.add_parser("delete-user", help="delete a user by id [--confirm]")
    sp.add_argument("--id", required=True)
    sp.add_argument("--keep-data", action="store_true", help="keep backup data (metadata only)")
    sp.add_argument("--confirm", action="store_true")
    sp.set_defaults(func=cmd_delete_user)

    # API requires LicenseID for Release/Revoke (HTTP 400 without it); Grant can
    # auto-pick a license, so it stays optional there.
    for name, fn, verb, lic_required in (("grant-license", cmd_grant_license, "grant", False),
                                         ("release-license", cmd_release_license, "release", True),
                                         ("revoke-license", cmd_revoke_license, "revoke", True)):
        sp = sub.add_parser(name, help=f"{verb} a license [--confirm]")
        sp.add_argument("--user-id", required=True)
        sp.add_argument("--license-id", required=lic_required)
        sp.add_argument("--confirm", action="store_true")
        sp.set_defaults(func=fn)

    sp = sub.add_parser("raw", help="raw API call (writes need --confirm)")
    sp.add_argument("method")
    sp.add_argument("path", help="e.g. /api/Users/{id}")
    sp.add_argument("--data", help="JSON request body")
    sp.add_argument("--confirm", action="store_true")
    sp.set_defaults(func=cmd_raw)

    return p


def main(argv=None) -> int:
    args = build_parser().parse_args(argv)
    client = Msp360Client(base=args.base)
    try:
        return args.func(client, args)
    except bc.BackupError as exc:  # includes Msp360Error + vault/HTTP transport errors
        print(f"[ERROR] {exc}", file=sys.stderr)
        log_error(str(exc)[:200], context=f"command={args.command}")
        return 1
    except Exception as exc:  # noqa: BLE001
        print(f"[ERROR] unexpected: {exc}", file=sys.stderr)
        log_error(f"unexpected: {exc}"[:200], context=f"command={args.command}")
        return 2


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