#!/usr/bin/env python3
"""CLI for the datto-edr skill - Datto EDR (Infocyte HUNT) REST API.

Read subcommands run freely. Mutating subcommands (scan, isolate, run-extension)
refuse to run unless --confirm is passed; without it they print what they WOULD
do and exit non-zero.

Output: --json emits raw JSON; otherwise a readable table/summary.

Usage examples:
  python edr.py status
  python edr.py orgs                         # clients (Organizations)
  python edr.py sites [--org <orgId>]        # sites (Locations)
  python edr.py agents [--org <orgId>] [--site <locationId>]
  python edr.py agent <agentId>
  python edr.py detections [--org <orgId>] [--severity 3] [--days 7]
  python edr.py detection <alertId>
  python edr.py sweep [--org <orgId>]        # per-client posture rollup
  python edr.py deploy-cmd --regkey <key> | --group <targetId>   # install one-liner
  python edr.py extensions                   # list response/collection extensions
  python edr.py tasks [--type "Scan - EDR"]  # scan/analysis jobs
  python edr.py scan --agent <agentId> [--agent <id2> ...] --confirm
  python edr.py isolate --agent <agentId> --confirm
  python edr.py cancel <taskId> --confirm
  python edr.py create-group --name "[TEST] X" --org <orgId> --confirm
  python edr.py mint-key --group <targetId> --key <10char> --confirm
  python edr.py raw GET Agents --filter '{"limit":1}'
"""
from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys

from edr_client import DattoEDRClient, DattoEDRError, DEFAULT_INSTANCE


def _log_skill_error(skill, msg, context=""):
    """Soft-fail: append a functional-error entry to errorlog.md (never throws)."""
    try:
        root = os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath(
            os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")
        )
        h = os.path.join(root, ".claude", "scripts", "log-skill-error.sh")
        if not os.path.exists(h):
            return
        a = ["bash", h, skill, msg]
        if context:
            a += ["--context", context]
        subprocess.run(a, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                       timeout=10)
    except Exception:
        pass


# Only GENUINELY expected/transient API responses are suppressed. Deliberately
# NARROW: a real auth failure (HTTP 401, e.g. the ~1yr token lapsing) MUST reach
# errorlog.md per CLAUDE.md - so we do NOT match broad words like "required",
# "unauthorized", "invalid value" that would swallow it.
_EXPECTED_ERROR_MARKERS = (
    "http 404", "not found", "no method to handle",
    "http 429", "too many requests",
)


def _is_expected_error(msg: str) -> bool:
    m = (msg or "").lower()
    return any(marker in m for marker in _EXPECTED_ERROR_MARKERS)


def _should_log_error(command: str, msg: str) -> bool:
    # raw is exploratory but a genuine failure through it (401/500) is still a
    # real API failure worth logging - filter only on expectedness, not command.
    if os.environ.get("EDR_SUPPRESS_ERRORLOG"):
        return False
    return not _is_expected_error(msg)


def _emit(obj, as_json: bool, table_fn=None) -> None:
    if as_json or table_fn is None:
        print(json.dumps(obj, indent=2, default=str))
    else:
        table_fn(obj)


def _trunc(s, n):
    s = "" if s is None else str(s)
    return s if len(s) <= n else s[: n - 1] + "…"


# --- table renderers ----------------------------------------------------------
def _t_status(s):
    print(f"Datto EDR tenant: {s.get('instance')}")
    print(f"  Organizations (clients): {s.get('organizations')}")
    print(f"  Scan target groups     : {s.get('targetGroups')}")
    print(f"  Agents                 : {s.get('agents')} "
          f"({s.get('agentsActive')} active, {s.get('agentsIsolated')} isolated)")
    print(f"  Alerts (all time)      : {s.get('alerts')}")


def _t_orgs(rows):
    print(f"{'ORGANIZATION':<34} {'AGENTS':>7} {'ALERTS':>7} {'SITES':>6}  ID")
    for o in rows:
        print(f"{_trunc(o.get('name'),34):<34} {o.get('agentCount',0):>7} "
              f"{o.get('alertCount',0):>7} {o.get('locationCount',0):>6}  {o.get('id')}")
    print(f"\n{len(rows)} organizations")


def _t_sites(rows):
    print(f"{'SITE (LOCATION)':<34} {'AGENTS':>7} {'ACTIVE':>7} {'ALERTS':>7}  ID")
    for t in rows:
        print(f"{_trunc(t.get('name'),34):<34} {t.get('agentCount',0):>7} "
              f"{t.get('activeAgentCount',0):>7} {t.get('alertCount',0):>7}  {t.get('id')}")
    print(f"\n{len(rows)} locations")


def _t_scan_targets(rows):
    print(f"{'SCAN TARGET GROUP':<34} {'AGENTS':>7} {'ACTIVE':>7} {'LAST SCAN':<20}  ID")
    for t in rows:
        print(f"{_trunc(t.get('name'),34):<34} {t.get('agentCount',0):>7} "
              f"{t.get('activeAgentCount',0):>7} {_trunc(t.get('lastScannedOn'),20):<20}  {t.get('id')}")
    print(f"\n{len(rows)} scan target groups")


def _agent_os(a):
    for k, label in (("osWindows", "Windows"), ("osLinux", "Linux"),
                     ("osOsx", "macOS"), ("osOther", "Other")):
        if a.get(k):
            return label
    return a.get("os") or "?"


def _t_agents(rows):
    print(f"{'HOSTNAME':<26} {'OS':<8} {'ONLINE':<7} {'ISO':<4} {'AV':<4} "
          f"{'VERSION':<13}  ID")
    for a in rows:
        print(f"{_trunc(a.get('hostname') or a.get('name'),26):<26} "
              f"{_agent_os(a):<8} {('yes' if a.get('active') else 'no'):<7} "
              f"{('ISO' if a.get('isolated') else '-'):<4} "
              f"{('on' if a.get('dattoAvEnabled') else '-'):<4} "
              f"{_trunc(a.get('version'),13):<13}  {a.get('id')}")
    print(f"\n{len(rows)} agents")


_SEV = {0: "info", 1: "low", 2: "medium", 3: "high", 4: "critical"}


def _t_detections(rows):
    print(f"{'WHEN':<20} {'SEV':<8} {'HOST':<22} {'ORG':<20} NAME")
    for a in rows:
        sev = _SEV.get(a.get("severity"), str(a.get("severity")))
        print(f"{_trunc(a.get('createdOn'),20):<20} {sev:<8} "
              f"{_trunc(a.get('hostname'),22):<22} "
              f"{_trunc(a.get('organizationName'),20):<20} "
              f"{_trunc(a.get('name') or a.get('description'),40)}")
    print(f"\n{len(rows)} detections")


def _t_sweep(s):
    rows = s.get("clients", [])
    print(f"{'CLIENT':<34} {'AGENTS':>7} {'ALERTS(7d)':>11} {'ALERTS(all)':>12}")
    for r in rows:
        print(f"{_trunc(r.get('organization'),34):<34} {r.get('agents',0):>7} "
              f"{r.get('alertsLast7d',0):>11} {r.get('alertsTotal',0):>12}")
    print(f"\n{len(rows)} clients (sorted by 7-day detection volume)")


def _t_tasks(rows):
    print(f"{'WHEN':<20} {'TYPE':<16} {'STATUS':<11} {'P%':>4}  ID")
    for t in rows:
        print(f"{_trunc(t.get('createdOn'),20):<20} {_trunc(t.get('type'),16):<16} "
              f"{_trunc(t.get('status'),11):<11} {str(t.get('progress','')):>4}  {t.get('id')}")
    print(f"\n{len(rows)} tasks")


# --- org -> target group resolution -------------------------------------------
def _agents_for_org(client, org_id, limit):
    """Agents across all sites (Locations) in an org. Agent.locationId -> Location."""
    loc_ids = [l.get("id") for l in client.list_locations(org_id=org_id) if l.get("id")]
    if not loc_ids:
        return []
    return client.list_agents(location_ids=loc_ids, limit=limit)


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="edr.py", description="Datto EDR (Infocyte HUNT) CLI")
    p.add_argument("--json", action="store_true", help="emit raw JSON")
    sub = p.add_subparsers(dest="command", required=True)

    sub.add_parser("status", help="tenant rollup")
    sub.add_parser("orgs", help="list organizations (clients)")
    sub.add_parser("clients", help="alias for orgs")

    sp = sub.add_parser("sites", help="list sites (Locations)")
    sp.add_argument("--org", help="filter by organizationId")

    sp = sub.add_parser("scan-targets", help="list scan target groups")
    sp.add_argument("--org", help="filter by organizationId")

    sp = sub.add_parser("agents", help="list agents")
    sp.add_argument("--org")
    sp.add_argument("--site", help="location id (a single site)")
    sp.add_argument("--limit", type=int, default=500)

    sp = sub.add_parser("agent", help="agent detail")
    sp.add_argument("id")

    sp = sub.add_parser("detections", help="list alerts")
    sp.add_argument("--org")
    sp.add_argument("--target-group", dest="target_group",
                    help="filter alerts by targetGroupId (Alert.targetGroupId is a "
                         "scan-target id, NOT a Location id - distinct from `agents --site`)")
    sp.add_argument("--severity", type=int, help="0 info..4 critical")
    sp.add_argument("--days", type=int)
    sp.add_argument("--limit", type=int, default=200)

    sp = sub.add_parser("detection", help="alert detail")
    sp.add_argument("id")

    sp = sub.add_parser("sweep", help="per-client posture rollup")
    sp.add_argument("--org")

    sub.add_parser("extensions", help="list agent extensions")

    sp = sub.add_parser("deploy-cmd", help="emit the agent install one-liner")
    sp.add_argument("--regkey", help="registration key string (used verbatim)")
    sp.add_argument("--group", help="target group id; looks up THAT group's key from "
                    "/agentKeys (never auto-picks an arbitrary cross-client key)")

    sp = sub.add_parser("scan", help="trigger an EDR scan on specific agent(s) (gated)")
    sp.add_argument("--agent", action="append", dest="agents", metavar="AGENT_ID",
                    help="agent id to scan (repeatable). REQUIRED - omitting scans the whole tenant.")
    sp.add_argument("--task-name", default="Scan - EDR")
    sp.add_argument("--confirm", action="store_true")

    sp = sub.add_parser("isolate", help="run a response extension (host isolation) on agent(s) (gated)")
    sp.add_argument("--agent", action="append", dest="agents", metavar="AGENT_ID",
                    help="agent id (repeatable)")
    sp.add_argument("--extension-id", help="extension id (else resolved from --extension-name)")
    sp.add_argument("--extension-name", default="Host Isolation [Win/Linux]")
    sp.add_argument("--confirm", action="store_true")

    sp = sub.add_parser("tasks", help="list recent userTasks (scan/analysis jobs)")
    sp.add_argument("--type", dest="task_type", help="filter by type e.g. 'Scan - EDR'")
    sp.add_argument("--limit", type=int, default=20)

    sp = sub.add_parser("task", help="userTask (scan job) detail")
    sp.add_argument("id")

    sp = sub.add_parser("cancel", help="cancel a running userTask e.g. a scan (gated)")
    sp.add_argument("id")
    sp.add_argument("--confirm", action="store_true")

    sp = sub.add_parser("create-group", help="create an EDR target group (gated)")
    sp.add_argument("--name", required=True)
    sp.add_argument("--org", required=True, help="organizationId")
    sp.add_argument("--confirm", action="store_true")

    sp = sub.add_parser("mint-key", help="mint a registration key for a group (gated)")
    sp.add_argument("--group", required=True, help="target group id")
    sp.add_argument("--key", required=True, help="10-char key string (caller-supplied)")
    sp.add_argument("--confirm", action="store_true")

    sp = sub.add_parser("raw", help="call any endpoint")
    sp.add_argument("method", help="GET/POST/PUT/DELETE")
    sp.add_argument("path", help="e.g. Agents or Agents/scan")
    sp.add_argument("--filter", help="LoopBack filter JSON (GET)")
    sp.add_argument("--data", help="request body JSON, or @path to read JSON from a file")
    sp.add_argument("--confirm", action="store_true",
                    help="required for non-GET methods")
    return p


def _gate(args, what):
    """Print the would-do and return False if --confirm missing."""
    if getattr(args, "confirm", False):
        return True
    print(f"[DRY-RUN] would {what}\n[BLOCKED] re-run with --confirm to execute.",
          file=sys.stderr)
    return False


def main(argv=None) -> int:
    args = build_parser().parse_args(argv)
    j = args.json
    client = DattoEDRClient()
    cmd = args.command

    try:
        if cmd == "status":
            _emit(client.get_status(), j, _t_status)
        elif cmd in ("orgs", "clients"):
            _emit(client.list_organizations(), j, _t_orgs)
        elif cmd == "sites":
            _emit(client.list_locations(org_id=args.org), j, _t_sites)
        elif cmd == "scan-targets":
            _emit(client.list_targets(org_id=args.org), j, _t_scan_targets)
        elif cmd == "agents":
            if args.site:
                if args.org:
                    print("[INFO] --site given; --org ignored (a site is already "
                          "org-scoped).", file=sys.stderr)
                rows = client.list_agents(location_id=args.site, limit=args.limit)
            elif args.org:
                rows = _agents_for_org(client, args.org, args.limit)
            else:
                rows = client.list_agents(limit=args.limit)
            _emit(rows, j, _t_agents)
        elif cmd == "agent":
            _emit(client.get_agent(args.id), j)
        elif cmd == "detections":
            _emit(client.list_alerts(org_id=args.org, target_group_id=args.target_group,
                                     severity=args.severity, days=args.days,
                                     limit=args.limit), j, _t_detections)
        elif cmd == "detection":
            _emit(client.get_alert(args.id), j)
        elif cmd == "sweep":
            _emit(client.sweep(org_id=args.org), j, _t_sweep)
        elif cmd == "extensions":
            _emit(client.list_extensions(), j)
        elif cmd == "deploy-cmd":
            regkey = args.regkey
            if not regkey and args.group:
                # Only the requested group's key - never auto-pick keys[0], which
                # is an arbitrary CROSS-CLIENT key (agents would land in the wrong org).
                keys = client.list_agent_keys()
                match = [k for k in (keys or []) if k.get("targetId") == args.group]
                if not match:
                    print(f"[ERROR] no registration key found for group {args.group}. "
                          f"Mint one with: mint-key --group {args.group} --key <10char>",
                          file=sys.stderr)
                    return 1
                regkey = match[0].get("id")
            if not regkey:
                print("[ERROR] deploy-cmd needs --regkey <key> or --group <targetId> "
                      "(refusing to auto-pick an arbitrary cross-client key).",
                      file=sys.stderr)
                return 2
            _emit(_deploy_payload(client, regkey), j, _t_deploy)
        elif cmd == "scan":
            ids = args.agents or []
            if not ids:
                print("[BLOCKED] scan requires at least one --agent <id>. Omitting "
                      "agents would scan the ENTIRE tenant (156-host footgun).",
                      file=sys.stderr)
                return 2
            if not _gate(args, f"EDR-scan {len(ids)} agent(s): {', '.join(ids)}"):
                return 2
            _emit(client.scan_agents(ids, task_name=args.task_name), j)
        elif cmd == "isolate":
            ids = args.agents or []
            if not ids:
                print("[BLOCKED] isolate requires at least one --agent <id>.",
                      file=sys.stderr)
                return 2
            ext_id = args.extension_id
            if not ext_id:  # resolve name -> id
                exts = client.list_extensions() or []
                want = args.extension_name.lower()
                # Exact first; then forgiving substring (tolerates console renames
                # like "Host Isolation [Windows]"). Exclude the paired "Restore"
                # extension so we never isolate when asked to isolate-undo, etc.
                exact = [e for e in exts if (e.get("name") or "").lower() == want]
                if exact:
                    match = exact
                else:
                    want_key = want.replace("[win/linux]", "").strip()
                    match = [e for e in exts
                             if want_key in (e.get("name") or "").lower()
                             and "restore" not in (e.get("name") or "").lower()]
                if not match:
                    print(f"[ERROR] extension '{args.extension_name}' not found. "
                          f"Run `extensions` to list, or pass --extension-id.",
                          file=sys.stderr)
                    return 1
                if len(match) > 1:
                    names = ", ".join(f"{e.get('name')} ({e.get('id')})" for e in match)
                    print(f"[ERROR] '{args.extension_name}' matched {len(match)} "
                          f"extensions: {names}. Pass --extension-id to disambiguate.",
                          file=sys.stderr)
                    return 1
                ext_id = match[0].get("id")
            if not _gate(args, f"run extension {ext_id} ('{args.extension_name}') "
                               f"on {len(ids)} agent(s): {', '.join(ids)}"):
                return 2
            _emit(client.run_extension_on_agents(ids, ext_id), j)
        elif cmd == "tasks":
            _emit(client.list_tasks(type_=args.task_type, limit=args.limit), j, _t_tasks)
        elif cmd == "task":
            _emit(client.get_task(args.id), j)
        elif cmd == "cancel":
            if not _gate(args, f"cancel userTask {args.id}"):
                return 2
            _emit(client.cancel_task(args.id), j)
        elif cmd == "create-group":
            if not _gate(args, f"create group '{args.name}' in org {args.org}"):
                return 2
            _emit(client.create_group(args.name, args.org), j)
        elif cmd == "mint-key":
            if not _gate(args, f"mint key '{args.key}' for group {args.group}"):
                return 2
            _emit(client.mint_registration_key(args.group, args.key), j)
        elif cmd == "raw":
            method = args.method.upper()
            if method != "GET" and not args.confirm:
                print(f"[BLOCKED] {method} requires --confirm.", file=sys.stderr)
                return 2
            filt = json.loads(args.filter) if args.filter else None
            if args.data and args.data.startswith("@"):
                with open(args.data[1:], "r", encoding="utf-8") as _f:
                    body = json.load(_f)
            elif args.data:
                body = json.loads(args.data)
            else:
                body = None
            # Same tenant-wide footgun guard the scan command has: a POST to any
            # */scan endpoint with no non-empty `where` scans the ENTIRE tenant.
            if method == "POST" and args.path.rstrip("/").lower().endswith("scan"):
                where = (body or {}).get("where") if isinstance(body, dict) else None
                if not where:
                    print("[BLOCKED] POST to a */scan endpoint with no non-empty "
                          "`where` would scan the ENTIRE tenant. Add a where filter, "
                          "e.g. {\"where\":{\"and\":[{\"id\":[\"<agentId>\"]}]}}.",
                          file=sys.stderr)
                    return 2
            _emit(client.raw(method, args.path, filt=filt, body=body), j)
        else:
            print(f"[ERROR] unknown command {cmd}", file=sys.stderr)
            return 1
    except DattoEDRError as exc:
        msg = str(exc)
        print(f"[ERROR] {msg}", file=sys.stderr)
        if _should_log_error(cmd, msg):
            _log_skill_error("datto-edr", msg, context=f"cmd={cmd}")
        return 1
    except KeyboardInterrupt:
        print("\n[ERROR] interrupted", file=sys.stderr)
        return 130
    except Exception as exc:  # unexpected - always surfaced AND logged
        msg = f"{type(exc).__name__}: {exc}"
        print(f"[ERROR] unexpected: {msg}", file=sys.stderr)
        _log_skill_error("datto-edr", msg, context=f"cmd={cmd} unexpected")
        return 1
    return 0


def _deploy_payload(client, regkey):
    instance = client.api_base_url.replace("https://", "").replace("/api", "").rstrip("/")
    cname = instance.split(".")[0]
    full_url = f"https://{instance}"
    keypart = f" -RegKey {regkey}" if regkey else ""
    # Pass the FULL -URL, never -InstanceName <cname>: the install script's loose
    # `.com` regex matches "zcom" inside azcomp4587 and leaves --url empty.
    oneliner = (
        "[System.Net.ServicePointManager]::SecurityProtocol = "
        "[Enum]::ToObject([System.Net.SecurityProtocolType], 3072); "
        "(new-object Net.WebClient).DownloadString("
        "\"https://raw.githubusercontent.com/Infocyte/PowershellTools/master/"
        "AgentDeployment/install_huntagent.ps1\") | iex; "
        f"Install-EDR -URL \"{full_url}\"{keypart}"
    )
    return {
        "instance": cname,
        "url": full_url,
        "regkey": regkey or "(none found - agent will register but await approval)",
        "powershell_oneliner": oneliner,
        "note": "Run on the target via GuruRMM script-execution or any remote-exec "
                "channel. RegKey auto-approves + adds to the key's target group.",
    }


def _t_deploy(p):
    print(f"Instance : {p['instance']}")
    print(f"RegKey   : {p['regkey']}")
    print(f"\nPowerShell one-liner (run on target, admin):\n\n{p['powershell_oneliner']}\n")
    print(f"[note] {p['note']}")


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