#!/usr/bin/env python3
"""
build_submission.py

Builds the central-team PIAM metrics submission JSON (7 attributes) and,
optionally, POSTs it over HTTPS.

  total_privileged_accounts   <- discovery pipeline (summary.json)
  the other six metrics        <- metrics_input.json (owned by other teams)

Standard library only (Python 3.6+). HTTPS submission uses urllib with full
TLS certificate + hostname verification; the bearer token is read from the
environment (PIAM_SUBMIT_TOKEN), never from a file or the command line.

Usage:
  # Build only (dry run — does NOT send):
  python3 build_submission.py --summary summary.json --metrics metrics_input.json \
      --output submission.json

  # Build and submit:
  export PIAM_SUBMIT_TOKEN='...'
  python3 build_submission.py --summary summary.json --metrics metrics_input.json \
      --output submission.json --submit
"""
import argparse
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone

TOKEN_ENV = "PIAM_SUBMIT_TOKEN"
SCHEMA_VERSION = "1.0"

# The 7 metric keys, in the order the central team lists them. Change these
# strings if the central team's schema requires different field names.
KEY_INCIDENTS       = "privileged_access_incidents"
KEY_TOTAL           = "total_privileged_accounts"
KEY_PAM_PCT         = "pam_managed_privileged_accounts_pct"
KEY_BYPASS          = "managed_account_bypass"
KEY_BREAK_GLASS     = "break_glass_instances"
KEY_OFFBOARDED_PCT  = "offboarded_within_time_pct"
KEY_TRAINED_PCT     = "people_trained_on_piam_pct"


def die(msg):
    print("ERROR: %s" % msg, file=sys.stderr)
    sys.exit(1)


def load_json(path, what):
    if not os.path.isfile(path):
        die("%s not found: %s" % (what, path))
    try:
        with open(path, "r", encoding="utf-8-sig") as fh:
            return json.load(fh)
    except (ValueError, OSError) as exc:
        die("could not read %s (%s): %s" % (what, path, exc))


def non_negative_int(value, label, warnings):
    try:
        n = int(value)
    except (TypeError, ValueError):
        die("%s must be an integer, got %r" % (label, value))
    if n < 0:
        die("%s must be >= 0, got %d" % (label, n))
    return n


def pct(numer, denom, label, warnings):
    """Percentage numer/denom, rounded to 1 dp. Guards divide-by-zero and
    flags out-of-range results rather than silently clamping."""
    if denom == 0:
        warnings.append("%s: denominator is 0; reporting null" % label)
        return None
    value = round(100.0 * numer / denom, 1)
    if value > 100.0:
        warnings.append("%s: %.1f%% exceeds 100 (numerator %d > denominator %d) "
                        "- check the inputs" % (label, value, numer, denom))
    return value


def total_from_summary(summary, override):
    if override is not None:
        return non_negative_int(override, "--total", [])
    if isinstance(summary, dict) and summary.get(KEY_TOTAL) is not None:
        return non_negative_int(summary[KEY_TOTAL], "summary.total_privileged_accounts", [])
    # group-by-month summaries carry no top-level total; use the latest month.
    if isinstance(summary, dict) and summary.get("months"):
        last = sorted(summary["months"])[-1]
        val = summary["months"][last].get(KEY_TOTAL)
        if val is not None:
            return non_negative_int(val, "summary.months[latest].total_privileged_accounts", [])
    die("could not find total_privileged_accounts in summary.json; pass --total N "
        "or point --summary at a normal (non group-by-month) summary.")


def build_payload(summary, metrics, total_override):
    warnings = []
    inp = metrics.get("inputs")
    if not isinstance(inp, dict):
        die("metrics file has no 'inputs' object.")

    period = metrics.get("reporting_period")
    if not (isinstance(period, str) and len(period) == 7 and period[4] == "-"
            and period[:4].isdigit() and period[5:].isdigit()):
        die("reporting_period must be 'YYYY-MM' (e.g. 2026-07); got %r" % period)

    total = total_from_summary(summary, total_override)

    incidents   = non_negative_int(inp.get("privileged_access_incidents", 0), "privileged_access_incidents", warnings)
    pam_managed = non_negative_int(inp.get("pam_managed_privileged_accounts", 0), "pam_managed_privileged_accounts", warnings)
    bypass      = non_negative_int(inp.get("managed_account_bypass", 0), "managed_account_bypass", warnings)
    breakglass  = non_negative_int(inp.get("break_glass_instances", 0), "break_glass_instances", warnings)

    off = inp.get("offboarding", {}) or {}
    off_ok    = non_negative_int(off.get("offboarded_within_sla", 0), "offboarding.offboarded_within_sla", warnings)
    off_total = non_negative_int(off.get("offboarded_total", 0), "offboarding.offboarded_total", warnings)

    tr = inp.get("piam_training", {}) or {}
    trained  = non_negative_int(tr.get("people_trained", 0), "piam_training.people_trained", warnings)
    in_scope = non_negative_int(tr.get("people_in_scope", 0), "piam_training.people_in_scope", warnings)

    metrics_block = {
        KEY_INCIDENTS:      incidents,
        KEY_TOTAL:          total,
        KEY_PAM_PCT:        pct(pam_managed, total, "pam_managed_privileged_accounts_pct", warnings),
        KEY_BYPASS:         bypass,
        KEY_BREAK_GLASS:    breakglass,
        KEY_OFFBOARDED_PCT: pct(off_ok, off_total, "offboarded_within_time_pct", warnings),
        KEY_TRAINED_PCT:    pct(trained, in_scope, "people_trained_on_piam_pct", warnings),
    }

    payload = {
        "schema_version": SCHEMA_VERSION,
        "reporting_period": period,
        "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "agency_ministry": metrics.get("agency_ministry", ""),
        "source": "privileged-accounts-discovery",
        "metrics": metrics_block,
    }
    return payload, warnings


def submit(payload, endpoint):
    if not endpoint:
        die("no submission endpoint; set submission.endpoint in the metrics file "
            "or pass --endpoint.")
    if not endpoint.lower().startswith("https://"):
        die("submission endpoint must be HTTPS, got: %s" % endpoint)
    token = os.environ.get(TOKEN_ENV)
    if not token:
        die("environment variable %s is not set; refusing to submit without a token."
            % TOKEN_ENV)

    body = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        endpoint, data=body, method="POST",
        headers={
            "Content-Type": "application/json",
            "Authorization": "Bearer " + token,
            "User-Agent": "piam-submission/1.0",
        })
    # Default context verifies the server certificate and hostname.
    ctx = ssl.create_default_context()
    try:
        with urllib.request.urlopen(req, timeout=30, context=ctx) as resp:
            status = resp.getcode()
            text = resp.read(2048).decode("utf-8", "replace")
        print("Submitted to %s -> HTTP %d" % (endpoint, status))
        if text.strip():
            print("Response: %s" % text.strip())
        return 0
    except urllib.error.HTTPError as e:
        detail = e.read(2048).decode("utf-8", "replace")
        die("submission rejected: HTTP %s %s\n%s" % (e.code, e.reason, detail))
    except urllib.error.URLError as e:
        die("submission failed (network/TLS): %s" % e.reason)


def main(argv=None):
    p = argparse.ArgumentParser(description="Build (and optionally submit) the PIAM metrics JSON.")
    p.add_argument("--summary", default="summary.json",
                   help="Aggregator summary.json (source of total_privileged_accounts).")
    p.add_argument("--metrics", default="metrics_input.json",
                   help="Metrics input file with the externally-owned values.")
    p.add_argument("--output", default=None,
                   help="Output path. Default: agency_submission_MMYYYY.json, "
                        "derived from reporting_period.")
    p.add_argument("--total", type=int, default=None,
                   help="Override total_privileged_accounts instead of reading summary.json.")
    p.add_argument("--endpoint", default=None, help="Override the HTTPS submission endpoint.")
    p.add_argument("--submit", action="store_true",
                   help="Actually POST over HTTPS (needs %s). Without this it is a dry run." % TOKEN_ENV)
    args = p.parse_args(argv)

    metrics = load_json(args.metrics, "metrics file")
    # summary is only needed when total is not overridden.
    summary = load_json(args.summary, "summary file") if args.total is None else {}

    payload, warnings = build_payload(summary, metrics, args.total)

    for w in warnings:
        print("WARNING: %s" % w, file=sys.stderr)

    # Default output name is derived from the reporting period: MM then YYYY,
    # e.g. reporting_period "2026-07" -> agency_submission_072026.json.
    output_path = args.output
    if output_path is None:
        period = payload["reporting_period"]         # already validated YYYY-MM
        output_path = "agency_submission_%s%s.json" % (period[5:7], period[:4])

    # Write the submission (restrictive perms; it is a governance record).
    out_dir = os.path.dirname(os.path.abspath(output_path))
    if out_dir:
        os.makedirs(out_dir, exist_ok=True)
    fd = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w", encoding="utf-8") as fh:
        json.dump(payload, fh, indent=2)
        fh.write("\n")
    print("Wrote %s" % output_path)
    print(json.dumps(payload["metrics"], indent=2))

    if args.submit:
        endpoint = args.endpoint or (metrics.get("submission", {}) or {}).get("endpoint")
        return submit(payload, endpoint)
    else:
        print("\nDry run - not submitted. Re-run with --submit (and %s set) to send."
              % TOKEN_ENV)
    return 0


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