#!/usr/bin/env python3
"""totalctrl-a11y — command-line client for the TotalCtrl Accessibility scanner.

Drives the hosted scanner via the public REST API (/api/v1/accessibility/*), so
CI can queue a scan, wait for it, print the result, and FAIL THE BUILD when a
gate is exceeded — without installing Chromium or axe locally. Stdlib only.

Auth (env, or flags):
  TOTALCTRL_API_TOKEN    API token   (Developer → API tokens, scope app:accessibility)
  TOTALCTRL_API_SECRET   API secret
  TOTALCTRL_API_BASE     default https://api.totalctrl.app

Examples:
  totalctrl-a11y projects
  totalctrl-a11y scans --project <uuid>
  totalctrl-a11y report --scan <uuid>
  # CI gate: queue a scan, wait, fail if any critical or >5 serious issues:
  totalctrl-a11y scan --project <uuid> --wait --max-critical 0 --max-serious 5

Exit codes: 0 = ok / gate passed · 1 = gate failed · 2 = usage or API error.
"""
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request

DEFAULT_BASE = os.environ.get("TOTALCTRL_API_BASE", "https://api.totalctrl.app")


def _die(msg, code=2):
    print(f"error: {msg}", file=sys.stderr)
    sys.exit(code)


def _creds(args):
    token = args.token or os.environ.get("TOTALCTRL_API_TOKEN")
    secret = args.secret or os.environ.get("TOTALCTRL_API_SECRET")
    if not token or not secret:
        _die("missing credentials — set TOTALCTRL_API_TOKEN and TOTALCTRL_API_SECRET "
             "(or pass --token/--secret)")
    return token, secret


def _request(args, method, path, payload=None):
    token, secret = _creds(args)
    url = args.base.rstrip("/") + path
    data = None
    req = urllib.request.Request(url, method=method)
    req.add_header("X-Api-Token", token)
    req.add_header("X-Api-Secret", secret)
    req.add_header("Accept", "application/json")
    if payload is not None:
        data = json.dumps(payload).encode("utf-8")
        req.data = data
        req.add_header("Content-Type", "application/json")
    try:
        with urllib.request.urlopen(req, timeout=args.timeout) as resp:
            body = json.loads(resp.read().decode("utf-8") or "{}")
    except urllib.error.HTTPError as e:
        detail = ""
        try:
            detail = json.loads(e.read().decode("utf-8")).get("error", {}).get("message", "")
        except Exception:
            pass
        _die(f"HTTP {e.code} on {method} {path}" + (f": {detail}" if detail else ""))
    except urllib.error.URLError as e:
        _die(f"network error contacting {url}: {e.reason}")
    return body.get("data", body)


# ── commands ──────────────────────────────────────────────────────────────────

def cmd_projects(args):
    data = _request(args, "GET", "/api/v1/accessibility/projects")
    projects = data.get("projects", [])
    if args.json:
        print(json.dumps(projects, indent=2))
        return 0
    if not projects:
        print("No accessibility projects.")
        return 0
    for p in projects:
        score = p.get("latest_score")
        score_s = f"{score}/100" if score is not None else "—"
        print(f"{p['uuid']}  {p['name']}  [{p.get('base_url','')}]  "
              f"score {score_s}  crit {p.get('latest_critical',0)} / serious {p.get('latest_serious',0)}")
    return 0


def cmd_scans(args):
    path = f"/api/v1/accessibility/projects/{args.project}/scans"
    if args.limit:
        path += f"?limit={int(args.limit)}"
    data = _request(args, "GET", path)
    scans = data.get("scans", [])
    if args.json:
        print(json.dumps(scans, indent=2))
        return 0
    for s in scans:
        print(f"{s['uuid']}  {s['status']:<10}  {s.get('profile','')}  "
              f"score {s.get('score','—')}  issues {s.get('total_violations',0)}  "
              f"(crit {s.get('critical',0)}, serious {s.get('serious',0)})  {s.get('created_at','')}")
    return 0


def _print_scan(scan, as_json):
    if as_json:
        print(json.dumps(scan, indent=2))
        return
    print(f"scan {scan['uuid']}  status={scan['status']}  profile={scan.get('profile','')}")
    print(f"  score {scan.get('score','—')}/100 · {scan.get('total_violations',0)} issue(s) · "
          f"{scan.get('pages_scanned',0)} page(s)")
    print(f"  critical {scan.get('critical',0)} · serious {scan.get('serious',0)} · "
          f"moderate {scan.get('moderate',0)} · minor {scan.get('minor',0)}")
    for v in scan.get("violations", []):
        wcag = ",".join(v.get("wcag", []) or [])
        print(f"  [{v.get('impact','?'):<8}] {v['rule_id']:<28} "
              f"WCAG {wcag or '—':<12} x{v.get('node_count',0)}  {v.get('help','')}")


def cmd_report(args):
    scan = _request(args, "GET", f"/api/v1/accessibility/scans/{args.scan}")
    _print_scan(scan, args.json)
    return 0


def _gate(scan, args):
    """Return (ok, reasons[]) applying the CI thresholds to a completed scan."""
    reasons = []
    crit, ser = scan.get("critical", 0), scan.get("serious", 0)
    score = scan.get("score")
    if args.max_critical is not None and crit > args.max_critical:
        reasons.append(f"critical {crit} > max {args.max_critical}")
    if args.max_serious is not None and ser > args.max_serious:
        reasons.append(f"serious {ser} > max {args.max_serious}")
    if args.min_score is not None and score is not None and score < args.min_score:
        reasons.append(f"score {score} < min {args.min_score}")
    return (not reasons, reasons)


def cmd_scan(args):
    payload = {"profile": args.profile} if args.profile else {}
    body = _request(args, "POST", f"/api/v1/accessibility/projects/{args.project}/scans",
                    payload=payload)
    scan = body.get("scan", body)
    uid = scan["uuid"]
    print(f"queued scan {uid} (status {scan.get('status','queued')})")

    if not args.wait:
        return 0

    # Poll until terminal state or timeout.
    deadline = time.monotonic() + args.wait_timeout
    last = None
    while time.monotonic() < deadline:
        scan = _request(args, "GET", f"/api/v1/accessibility/scans/{uid}")
        st = scan.get("status")
        if st != last:
            print(f"  … {st}")
            last = st
        if st in ("completed", "error", "failed"):
            break
        time.sleep(args.poll_interval)
    else:
        _die(f"scan {uid} did not finish within {args.wait_timeout}s", code=2)

    if scan.get("status") != "completed":
        _die(f"scan {uid} ended in status '{scan.get('status')}'", code=2)

    _print_scan(scan, args.json)

    ok, reasons = _gate(scan, args)
    if not ok:
        print("GATE FAILED: " + "; ".join(reasons), file=sys.stderr)
        return 1
    if any(getattr(args, a) is not None for a in ("max_critical", "max_serious", "min_score")):
        print("gate passed")
    return 0


# ── arg parsing ─────────────────────────────────────────────────────────────

def build_parser():
    p = argparse.ArgumentParser(prog="totalctrl-a11y", description=__doc__,
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("--base", default=DEFAULT_BASE, help=f"API base (default {DEFAULT_BASE})")
    p.add_argument("--token", help="API token (or TOTALCTRL_API_TOKEN)")
    p.add_argument("--secret", help="API secret (or TOTALCTRL_API_SECRET)")
    p.add_argument("--timeout", type=float, default=30, help="per-request timeout (s)")
    p.add_argument("--json", action="store_true", help="machine-readable JSON output")
    sub = p.add_subparsers(dest="cmd", required=True)

    sp = sub.add_parser("projects", help="list accessibility projects")
    sp.set_defaults(func=cmd_projects)

    sp = sub.add_parser("scans", help="list scans for a project")
    sp.add_argument("--project", required=True, help="project uuid")
    sp.add_argument("--limit", type=int, default=20)
    sp.set_defaults(func=cmd_scans)

    sp = sub.add_parser("report", help="show a scan + its violations")
    sp.add_argument("--scan", required=True, help="scan uuid")
    sp.set_defaults(func=cmd_report)

    sp = sub.add_parser("scan", help="queue a scan; optionally wait + gate the build")
    sp.add_argument("--project", required=True, help="project uuid")
    sp.add_argument("--profile", help="wcag22aa | en301549 | section508")
    sp.add_argument("--wait", action="store_true", help="poll until the scan finishes")
    sp.add_argument("--wait-timeout", type=float, default=600, help="max seconds to wait")
    sp.add_argument("--poll-interval", type=float, default=5, help="seconds between polls")
    sp.add_argument("--max-critical", type=int, help="fail if critical issues exceed this")
    sp.add_argument("--max-serious", type=int, help="fail if serious issues exceed this")
    sp.add_argument("--min-score", type=int, help="fail if score is below this")
    sp.set_defaults(func=cmd_scan)
    return p


def main(argv=None):
    args = build_parser().parse_args(argv)
    try:
        return args.func(args)
    except KeyboardInterrupt:
        return 130


if __name__ == "__main__":
    sys.exit(main())
