#!/usr/bin/env python3
"""Reconcile a martech tool register with spend, SSO, OAuth and seat exports.

Usage (run from the audit folder, by the script's path in the skill folder):
  python3 <skill folder>/scripts/stack_inventory.py --tools TOOLS.csv
      [--spend SPEND.csv] [--sso SSO.csv] [--sso-retention-days N]
      [--oauth OAUTH.csv] [--seats SEATS.csv]
      [--company-domains example.com,example.org] [--vocabulary FILE]
      [--as-of YYYY-MM-DD] [--dormant-days 45] [--decision-buffer 14]
      [--growth 0.0] [--top-share 0.8] [--variance 0.10]
      [--date-format %d/%m/%Y] [--json]

Inputs (CSV with comma, semicolon or tab separators; UTF-8 or Latin-1). Dates in
every file are YYYY-MM-DD unless --date-format gives the format, for example
%m/%d/%Y; a filled date that cannot be read stops the run and names the file,
row and column. A blank last_login means no login.
  --tools   the register, with the columns of assets/tool-register-template.csv.
            A row counts unless its status is retired or out_of_scope; a Note
            lists rows with any other word.
  --spend   12 months of accounts payable, card and expense lines. Headers are
            matched case-insensitively: date (transaction date, posted date,
            payment date, invoice date, booking date), vendor (merchant, payee,
            supplier, name), amount (total, debit; negative amounts are credits),
            currency (ccy, currency code; missing means UNSPECIFIED), payment
            source (payment method, card name, account, source).
  --sso     Okta Application Usage CSV (Instance name, Login Date_ISO8601; 90 days
            of events), or a generic file with the columns app, user_ref and
            last_login, which needs --sso-retention-days (7 or 30 for Entra)
  --oauth   Google Workspace "Download Accessed Apps" CSV (App Name, Users,
            Requested Services, Verification Status, Requested Services with Scopes)
  --seats   tool user exports normalised to assets/seats-template.csv

Checks: INV01-INV08 inventory, CON01-CON06 contracts and renewals,
USE01-USE05 usage and seats, OVL01-OVL03 overlap, ACC01-ACC06 access.
A check whose input columns are blank in every row prints NOT CHECKED, never PASS.
INV08 prints only when the register has a retired row.
Rules: references/inventory-and-cost.md, references/renewals-and-contracts.md,
references/overlap-and-dispositions.md and references/governance-access-ai.md.

Privacy: prints tool names, vendor descriptors, app names, counts, email
domains and row numbers (the header is row 1). It never prints email
addresses or person names, and masks anything shaped like an email address.

Exit code: 0 no FAIL, 1 at least one FAIL (the normal result of an audit
with findings), 2 bad input or usage.
Standard library only. Reads local files, makes no network calls, writes nothing.
"""

import argparse
import csv
import io
import json
import os
import re
import sys
from collections import defaultdict
from datetime import date, datetime, timedelta

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DEFAULT_VOCABULARY = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "assets", "capability-vocabulary.csv"))

OUT_OF_SCOPE = {"retired", "out_of_scope"}
MOVING = {"migrating", "retiring"}
DOCUMENTED_STATUS = {"active", "trial", "migrating", "retiring", "retired", "out_of_scope"}
APART = {"media", "services"}
APART_LABELS = (("media", "media"), ("services", "services"), ("retired", "retired or out of scope"))
NOT_TOOL_SPEND = {"media", "services", "free", "netted"}
CHANGE_DISPOSITIONS = {"consolidate", "replace", "retire", "investigate"}
REVIEW_DISPOSITIONS = {"renegotiate", "right-size"}
IDENTITY_CAPABILITIES = {"crm", "cdp_event_collection", "lifecycle_messaging", "marketing_automation_email",
                         "consent_management", "identity_resolution", "warehouse"}
CONTRACT_FIELDS = ["term_end", "notice_days", "notice_mechanism", "auto_renew", "pricing_basis", "reduction_rule"]
GENERIC_LOCAL_PARTS = {"admin", "marketing", "info", "hello", "team", "ops", "growth", "sales", "support",
                       "analytics", "it", "web", "agency", "shared"}
# Google's restricted Gmail and Drive scopes, from the Gmail and Drive API scope pages (checked 2026-11-06).
RESTRICTED_SCOPES = {"mail.google.com", "gmail.readonly", "gmail.compose", "gmail.insert", "gmail.modify",
                     "gmail.metadata", "gmail.settings.basic", "gmail.settings.sharing", "drive", "drive.readonly",
                     "drive.activity", "drive.activity.readonly", "drive.meet.readonly", "drive.metadata",
                     "drive.metadata.readonly", "drive.scripts"}
# Scopes the audit names as sensitive: calendars, contacts, directory and admin data, documents, sending.
SENSITIVE_SCOPE_PREFIXES = ("calendar", "contacts", "directory", "admin.", "spreadsheets", "documents", "forms",
                            "script.", "gmail.", "drive.apps")
NON_SENSITIVE_SCOPES = {"gmail.labels", "gmail.addons.current.action.compose", "gmail.addons.current.message.action",
                        "drive.file", "drive.appdata", "drive.appfolder", "drive.install"}
RESTRICTED_SERVICES = {"gmail": "Gmail", "drive": "Google Drive"}
SENSITIVE_SERVICES = {"calendar": "Google Calendar", "contacts": "Contacts", "sheets": "Google Sheets",
                      "docs": "Google Docs", "directory": "Directory", "admin": "Admin"}
PROCESSOR_PREFIXES = {"paypal", "sq", "paddle.net", "paddle", "fs"}
LEGAL_SUFFIXES = {"inc", "llc", "ltd", "gmbh", "corp", "co", "limited", "plc", "bv", "sl", "sa", "ag", "pty"}
UNKNOWN = {"", "unknown", "n/a", "na", "tbc", "tbd", "?"}
BLANK = {"", "unknown", "tbc", "tbd", "?"}
NOT_APPLICABLE = {"n/a", "na"}
DISABLED = {"disabled", "deactivated", "suspended", "removed", "deleted"}
MFA_ENFORCED = {"enforced", "required"}
IDP_GONE = {"deactivated", "suspended", "deprovisioned", "deleted", "disabled"}
IDP_ABSENT = {"not_in_idp", "not in idp"}
EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")

CHECKS = {
    "INV01": "unregistered-spend", "INV02": "unregistered-app", "INV03": "multi-payer",
    "INV04": "owner-missing", "INV05": "account-ownership", "INV06": "no-evidence",
    "INV07": "personal-data-without-dpa", "INV08": "retired-still-active", "CON01": "terms-unknown", "CON02": "spend-variance",
    "CON03": "renewal-locked", "CON04": "decision-overdue", "CON05": "discount-ending",
    "CON06": "term-end-past", "USE01": "dormant", "USE02": "reclaimable-seats",
    "USE03": "retention-short", "USE04": "oversized-tier", "USE05": "unassigned-seats",
    "OVL01": "shared-job", "OVL02": "duplicate-execution", "OVL03": "duplicate-authority",
    "ACC01": "admin-count", "ACC02": "external-admin", "ACC03": "shared-login",
    "ACC04": "admin-mfa", "ACC05": "offboarded-account", "ACC06": "restricted-oauth-scope",
}
PASS_TEXT = {
    "INV01": "every charge in the window matches a register row",
    "INV02": "every SSO and OAuth app with users matches a register row",
    "INV03": "no register tool is paid from two or more payment sources",
    "INV04": "every in-scope tool has a business and a technical owner role",
    "INV05": "no critical tool account is held by an agency or by an unknown owner",
    "INV06": "every subscription or usage tool has spend, SSO, OAuth or seat evidence",
    "INV07": "every tool that holds personal data has a DPA on file",
    "INV08": "no retired tool shows a recent charge, SSO sign-in, OAuth grant or enabled seat",
    "CON01": "every annual tool in the top share of spend has all six contract fields",
    "CON02": "every 12-month actual is within the variance of its contract value",
    "CON03": "no notice boundary has passed while its term is still running",
    "CON04": "no review-by date has passed while its notice boundary is still ahead",
    "CON05": "no discount ends within 120 days",
    "CON06": "no active annual tool has a term end in the past",
    "USE01": "no enabled account is dormant",
    "USE02": "no paid seats are reclaimable",
    "USE03": "every login source keeps at least as many days as the dormancy threshold",
    "USE04": "no capacity tier fits the next lower tier",
    "USE05": "every paid seat is assigned",
    "OVL01": "no capability is shared by two active tools",
    "OVL02": "no customer action is run by two tools",
    "OVL03": "no object or field has two systems of record",
    "ACC01": "every critical tool with seat rows has 2 to 4 company admins",
    "ACC02": "no enabled admin uses an external domain",
    "ACC03": "no enabled account uses a shared or generic login",
    "ACC04": "every enabled admin on a critical tool has MFA or SSO enforced",
    "ACC05": "no enabled account belongs to a deactivated identity provider user",
    "ACC06": "no registered app holds a restricted Google scope",
}
SEVERITY_ORDER = ["FAIL", "WARN", "INFO", "PASS", "NOT CHECKED"]


# ---------- small helpers ----------

def fail_input(message):
    print(f"Input error: {mask(message)}", file=sys.stderr)
    sys.exit(2)


def mask(value):
    return EMAIL_RE.sub("[email]", str(value))


def norm_header(name):
    return re.sub(r"[^a-z0-9]+", "_", name.strip().lower()).strip("_")


def detect_delimiter(first_line):
    counts = {d: first_line.count(d) for d in (",", ";", "\t")}
    best = max(counts, key=lambda d: (counts[d], d == ","))
    return best if counts[best] else ","


def read_table(path, label):
    """Rows as dicts keyed by normalised header, each with its spreadsheet row number in _row."""
    try:
        with open(path, "rb") as handle:
            raw = handle.read()
    except OSError as exc:
        fail_input(f"cannot read the {label} file {path}: {exc.strerror or exc}")
    if b"\x00" in raw:
        fail_input(f"the {label} file {path} is not a text CSV; save a workbook or UTF-16 export as CSV UTF-8")
    text = ""
    for encoding in ("utf-8-sig", "cp1252", "latin-1"):
        try:
            text = raw.decode(encoding)
            break
        except UnicodeDecodeError:
            continue
    text = text.replace("\r\n", "\n").replace("\r", "\n")
    first = text.split("\n", 1)[0]
    if not first.strip():
        fail_input(f"the {label} file {path} has no header row")
    reader = csv.reader(io.StringIO(text), delimiter=detect_delimiter(first))
    rows = []
    try:
        header = [norm_header(h) for h in next(reader)]
        for number, values in enumerate(reader, start=2):
            if not any(v.strip() for v in values):
                continue
            row = {"_row": number}
            for index, key in enumerate(header):
                if key and key not in row:
                    row[key] = values[index].strip() if index < len(values) else ""
            rows.append(row)
    except csv.Error as exc:
        fail_input(f"the {label} file {path} cannot be read as CSV: {exc}")
    return rows, header


def pick(header, *aliases):
    for alias in aliases:
        if alias in header:
            return alias
    return None


def split_multi(value):
    return [part.strip() for part in (value or "").split(";") if part.strip()]


def low(value):
    return (value or "").strip().lower()


def listed(value):
    """Lowercased items of a multi-value cell, without the words that mean unknown or n/a."""
    return [item for item in (low(part) for part in split_multi(value)) if item not in UNKNOWN]


def plural(n, word):
    return f"{n} {word}" + ("" if n == 1 else "s")


def parse_date(value, extra_format=None):
    text = (value or "").strip()
    if not text:
        return None
    if extra_format:
        try:
            return datetime.strptime(text, extra_format).date()
        except ValueError:
            pass
    try:
        return date.fromisoformat(text[:10])
    except ValueError:
        pass
    for fmt in ("%Y/%m/%d", "%d %b %Y", "%d %B %Y"):
        try:
            return datetime.strptime(text, fmt).date()
        except ValueError:
            pass
    match = re.match(r"([A-Za-z]{3,9})\.? (\d{1,2}), (\d{4})", text)
    if match:
        for fmt in ("%b %d %Y", "%B %d %Y"):
            try:
                return datetime.strptime(" ".join(match.groups()), fmt).date()
            except ValueError:
                pass
    return None


def readable_date(value, date_format, where):
    """A date cell: None when blank, marked unknown or never; a filled cell that cannot be read stops the run."""
    text = (value or "").strip()
    if low(text) in UNKNOWN or low(text) == "never":
        return None
    parsed = parse_date(text, date_format)
    if parsed is None:
        fail_input(f"{where}: cannot read the date '{text}'. Write YYYY-MM-DD, or pass --date-format "
                   "(%m/%d/%Y for month-first US exports, %d/%m/%Y for day-first dates).")
    return parsed


def login_unknown(value):
    """True when a last-login cell says nobody knows, which differs from a blank cell: no login."""
    return low(value) in UNKNOWN and bool(low(value))


def parse_number(value):
    text = (value or "").strip()
    if low(text) in UNKNOWN:
        return None
    negative = text.startswith("-") or text.endswith("-") or (text.startswith("(") and text.endswith(")"))
    digits = re.sub(r"[^0-9.,]", "", text)
    if not digits:
        return None
    if "," in digits and "." in digits:
        if digits.rfind(",") > digits.rfind("."):
            digits = digits.replace(".", "").replace(",", ".")
        else:
            digits = digits.replace(",", "")
    elif "," in digits:
        head, _, tail = digits.rpartition(",")
        digits = f"{head}.{tail}" if len(tail) == 2 and "," not in head else digits.replace(",", "")
    try:
        number = float(digits)
    except ValueError:
        return None
    return -number if negative else number


def money(amount):
    return f"{amount:,.2f}"


def is_admin(row):
    return low(row.get("is_admin")) == "yes"


def local_part(row):
    email = low(row.get("email"))
    return email.split("@")[0].split("+")[0] if "@" in email else ""


def domain_of(row):
    email = low(row.get("email"))
    return email.split("@", 1)[1] if "@" in email else ""


def in_domains(domain, domains):
    return any(domain == d or domain.endswith("." + d) for d in domains)


def mfa_state(row):
    """enforced, n/a (no interactive sign-in), unknown, or not_enforced (optional, off, none)."""
    value = low(row.get("mfa"))
    if value in MFA_ENFORCED:
        return "enforced"
    if value in NOT_APPLICABLE:
        return "n/a"
    if value in BLANK:
        return "unknown"
    return "not_enforced"


def rows_text(numbers, limit=20):
    """Compress row numbers into ranges: 'rows 2-5, 9'."""
    numbers = sorted(set(numbers))
    if not numbers:
        return "no rows"
    parts, start, prev = [], numbers[0], numbers[0]
    for n in numbers[1:] + [None]:
        if n is not None and n == prev + 1:
            prev = n
            continue
        parts.append(str(start) if start == prev else f"{start}-{prev}")
        if n is not None:
            start = prev = n
    more = f" and {len(parts) - limit} more ranges" if len(parts) > limit else ""
    return ("row " if len(numbers) == 1 else "rows ") + ", ".join(parts[:limit]) + more


def normalise_name(value):
    """Lowercase, drop payment processor prefixes, punctuation, legal suffixes and trailing numbers."""
    text = low(value)
    if "*" in text:
        head, _, tail = text.partition("*")
        text = tail if head.strip() in PROCESSOR_PREFIXES else f"{head} {tail}"
    tokens = re.sub(r"[^a-z0-9]+", " ", text).split()
    while tokens and (tokens[-1] in LEGAL_SUFFIXES or tokens[-1].isdigit()):
        tokens.pop()
    return " ".join(tokens)


def classify_scopes(scopes_text, services_text):
    """Restricted and sensitive Google scopes, and whether they were inferred from service names only."""
    text = scopes_text or ""
    names = {m.group(1).lower() for m in re.finditer(r"googleapis\.com/auth/([A-Za-z0-9._-]+)", text)}
    if re.search(r"mail\.google\.com", text, re.IGNORECASE):
        names.add("mail.google.com")
    for token in re.split(r"[;,\s]+", text.lower()):
        if "googleapis" not in token and "mail.google" not in token and re.fullmatch(r"[a-z]+(\.[a-z0-9_-]+)+", token):
            names.add(token)
    if names:
        restricted = sorted(n for n in names if n in RESTRICTED_SCOPES)
        sensitive = sorted(n for n in names if n not in RESTRICTED_SCOPES and n not in NON_SENSITIVE_SCOPES
                           and n.startswith(SENSITIVE_SCOPE_PREFIXES))
        return restricted, sensitive, False
    services = low(services_text)
    restricted = [name for token, name in RESTRICTED_SERVICES.items() if token in services]
    sensitive = [name for token, name in SENSITIVE_SERVICES.items() if token in services]
    return restricted, sensitive, bool(restricted or sensitive)


def label(tools, tool_id):
    return f"{tool_id} {tools[tool_id]['tool_name']}" if tool_id in tools else tool_id


class Matcher:
    """Matches billing descriptors and app names to register rows by tool name and alias.

    A vendor name alone never matches, because one vendor often sells several products
    (Twilio, Google, Microsoft, Adobe, Salesforce). It only adds a hint to the unmatched line.
    """

    def __init__(self, tools):
        self.exact = defaultdict(set)
        self.inner = []
        self.vendors = defaultdict(set)
        for tool in tools.values():
            for value in [tool["tool_name"]] + split_multi(tool.get("aliases", "")):
                key = normalise_name(value)
                if key in UNKNOWN:
                    continue
                self.exact[key].add(tool["tool_id"])
                if len(key.replace(" ", "")) >= 4:
                    self.inner.append((key, tool["tool_id"]))
            vendor = normalise_name(tool.get("vendor", ""))
            if vendor:
                self.vendors[vendor].add(tool["tool_id"])

    def match(self, value):
        key = normalise_name(value)
        if not key:
            return None
        ids = self.exact.get(key, set())
        if len(ids) == 1:
            return next(iter(ids))
        padded, hits = f" {key} ", {}
        for inner_key, tool_id in self.inner:
            if f" {inner_key} " in padded:
                hits[tool_id] = max(hits.get(tool_id, 0), len(inner_key))
        if not hits:
            return None
        best = max(hits.values())
        winners = [tool_id for tool_id, length in hits.items() if length == best]
        return winners[0] if len(winners) == 1 else None

    def hint(self, value, tools):
        ids = sorted(self.vendors.get(normalise_name(value), set()))
        if not ids:
            return ""
        return (f" Its vendor name matches register {'row' if len(ids) == 1 else 'rows'} "
                f"{', '.join(label(tools, i) for i in ids)}; if it is the same product, add it as an alias.")


class Report:
    def __init__(self):
        self.findings = []
        self.ran = set()
        self.notes = {}

    def add(self, severity, check, message, evidence=""):
        self.findings.append({"severity": severity, "check": check, "name": CHECKS[check],
                              "message": mask(message), "evidence": mask(evidence)})

    def not_checked(self, checks, reason):
        for check in checks:
            self.add("NOT CHECKED", check, reason)

    def run(self, check, note=""):
        self.ran.add(check)
        if note:
            self.notes[check] = note

    def gate(self, checks, present, reason, note=""):
        """Run the checks only when their input columns hold data; otherwise NOT CHECKED, never PASS."""
        if present:
            for check in checks:
                self.run(check, note)
        else:
            self.not_checked(checks, reason)
        return bool(present)

    def finish(self):
        seen = {f["check"] for f in self.findings}
        for check in CHECKS:
            if check in self.ran and check not in seen:
                note = f" ({self.notes[check]})" if self.notes.get(check) else ""
                self.add("PASS", check, PASS_TEXT[check] + note + ".")
        rank = {check: i for i, check in enumerate(CHECKS)}
        self.findings.sort(key=lambda f: (SEVERITY_ORDER.index(f["severity"]), rank[f["check"]]))

    def counts(self):
        return {s: sum(1 for f in self.findings if f["severity"] == s) for s in SEVERITY_ORDER}


# ---------- loading ----------

def load_vocabulary(path):
    if not path or not os.path.exists(path):
        return {}
    rows, header = read_table(path, "vocabulary")
    if "capability_id" not in header:
        fail_input(f"the vocabulary file {path} needs a capability_id column")
    return {low(r["capability_id"]): low(r.get("layer", "")) for r in rows if r.get("capability_id")}


def register_date(row, column, date_format):
    return readable_date(row.get(column), date_format, f"tools row {row['_row']}, column {column}")


def load_tools(path, date_format=None):
    rows, header = read_table(path, "tools")
    for column in ("tool_id", "tool_name"):
        if column not in header:
            fail_input(f"the tools file needs a {column} column (see assets/tool-register-template.csv)")
    tools = {}
    for row in rows:
        tool_id = row.get("tool_id", "").strip()
        if not tool_id:
            continue
        if tool_id in tools:
            fail_input(f"tool_id {tool_id} appears twice in the tools file (rows {tools[tool_id]['_row']} and {row['_row']})")
        tool = dict(row)
        tool["status_l"] = re.sub(r"[\s-]+", "_", low(row.get("status")))
        tool["critical_b"] = low(row.get("critical")) == "yes"
        tool["capability_list"] = listed(row.get("capabilities"))
        tool["actions_list"] = listed(row.get("customer_actions"))
        tool["sor_list"] = listed(row.get("system_of_record_for"))
        tool["spend_type_l"] = low(row.get("spend_type"))
        tool["period_l"] = low(row.get("billing_period")).replace("_", "-")
        tool["contract_value"] = parse_number(row.get("contract_value_annual"))
        tool["currency_u"] = (row.get("currency") or "").strip().upper() or "UNSPECIFIED"
        tool["term_end_d"] = register_date(row, "term_end", date_format)
        notice = parse_number(row.get("notice_days"))
        tool["notice_n"] = int(notice) if notice is not None else None
        for column in ("paid_seats", "committed_units", "measured_peak_units", "next_lower_tier_units", "change_lead_days"):
            tool[column + "_n"] = parse_number(row.get(column))
        tool["discount_end_d"] = register_date(row, "discount_end", date_format)
        tool["evidence_date_d"] = register_date(row, "evidence_date", date_format)
        tools[tool_id] = tool
    return tools


# ---------- main ----------

def parse_args():
    parser = argparse.ArgumentParser(description="Reconcile a martech tool register with spend, SSO, OAuth and seat exports. Read-only.")
    parser.add_argument("--tools", required=True)
    parser.add_argument("--spend")
    parser.add_argument("--sso")
    parser.add_argument("--sso-retention-days", type=int)
    parser.add_argument("--oauth")
    parser.add_argument("--seats")
    parser.add_argument("--company-domains", default="")
    parser.add_argument("--vocabulary", default=DEFAULT_VOCABULARY)
    parser.add_argument("--as-of")
    parser.add_argument("--dormant-days", type=int, default=45)
    parser.add_argument("--decision-buffer", type=int, default=14)
    parser.add_argument("--growth", type=float, default=0.0)
    parser.add_argument("--top-share", type=float, default=0.8)
    parser.add_argument("--variance", type=float, default=0.10)
    parser.add_argument("--date-format")
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()
    if args.as_of:
        try:
            args.as_of_d = date.fromisoformat(args.as_of)
        except ValueError:
            fail_input(f"--as-of must be YYYY-MM-DD, got {args.as_of}")
    else:
        args.as_of_d = date.today()
    if not 0 < args.top_share <= 1:
        fail_input("--top-share must be above 0 and at most 1")
    return args


def main():
    args = parse_args()
    as_of = args.as_of_d
    window_start = as_of - timedelta(days=365)
    report = Report()
    notes = []
    act_overdue, act_soon, act_apps = [], [], []
    vocabulary = load_vocabulary(args.vocabulary)
    tools = load_tools(args.tools, args.date_format)
    matcher = Matcher(tools)
    scope = {tid: t for tid, t in tools.items() if t["status_l"] not in OUT_OF_SCOPE}
    company = [d.strip().lower().lstrip("@") for d in args.company_domains.split(",") if d.strip()]
    inputs = {"tools": {"file": os.path.basename(args.tools), "rows": len(tools), "in_scope": len(scope)}}

    undocumented = defaultdict(list)
    for tid, t in tools.items():
        if t["status_l"] not in DOCUMENTED_STATUS:
            undocumented[t["status_l"] or "blank"].append(tid)
    if undocumented:
        notes.append("status outside the documented values, kept in scope: "
                     + "; ".join(f"{word} ({', '.join(ids)})" for word, ids in sorted(undocumented.items())))

    if vocabulary:
        unknown_caps = sorted({c for t in scope.values() for c in t["capability_list"] if c not in vocabulary})
        if unknown_caps:
            notes.append("capabilities not in the vocabulary (overlap compares exact IDs only): " + ", ".join(unknown_caps))

    # ----- spend -----
    tool_rows = defaultdict(list)
    descriptors = defaultdict(set)
    unmatched = defaultdict(list)
    spend_info = None
    source_known = False
    if args.spend:
        rows, header = read_table(args.spend, "spend")
        date_col = pick(header, "date", "transaction_date", "posted_date", "payment_date", "invoice_date", "booking_date")
        vendor_col = pick(header, "vendor", "merchant", "payee", "supplier", "name")
        amount_col = pick(header, "amount", "total", "debit")
        currency_col = pick(header, "currency", "ccy", "currency_code")
        source_col = pick(header, "payment_source", "payment_method", "card_name", "account", "source")
        source_known = bool(source_col)
        if not (date_col and vendor_col and amount_col):
            fail_input("the spend file needs date, vendor and amount columns (or their aliases; see the usage text)")
        dates, outside, unreadable = [], 0, []
        for row in rows:
            when = readable_date(row.get(date_col), args.date_format,
                                 f"spend file {os.path.basename(args.spend)}, row {row['_row']}, column {date_col}")
            amount = parse_number(row.get(amount_col))
            if when is None or amount is None:
                unreadable.append(row["_row"])
                continue
            dates.append(when)
            if not window_start < when <= as_of:
                outside += 1
                continue
            record = {"row": row["_row"], "date": when, "amount": amount,
                      "currency": (row.get(currency_col) or "").strip().upper() if currency_col else "",
                      "source": (row.get(source_col) or "").strip() if source_col else "",
                      "vendor": row.get(vendor_col, "")}
            record["currency"] = record["currency"] or "UNSPECIFIED"
            record["source"] = record["source"] or "unknown source"
            tool_id = matcher.match(record["vendor"])
            if tool_id:
                tool_rows[tool_id].append(record)
                descriptors[tool_id].add(record["vendor"].strip())
            else:
                unmatched[normalise_name(record["vendor"]) or "(blank vendor)"].append(record)
        spend_info = {"file": os.path.basename(args.spend), "rows": len(rows),
                      "first_date": min(dates).isoformat() if dates else None,
                      "last_date": max(dates).isoformat() if dates else None,
                      "outside_window": outside, "unreadable_rows": rows_text(unreadable) if unreadable else ""}
        inputs["spend"] = spend_info

    def actual(tool_id, currency):
        return sum(r["amount"] for r in tool_rows.get(tool_id, []) if r["currency"] == currency)

    # ----- SSO -----
    sso_users = defaultdict(dict)      # tool_id -> user -> last login
    sso_unmatched = defaultdict(dict)  # app name -> user -> last login
    sso_unclear = set()                # (tool_id, user) pairs whose last login is marked unknown
    sso_retention = args.sso_retention_days
    if args.sso:
        rows, header = read_table(args.sso, "SSO")
        if "instance_name" in header:
            app_col, kind = "instance_name", "Okta Application Usage"
            user_col = pick(header, "login", "person", "application_username")
            when_col = pick(header, "login_date_iso8601", "login_date")
        elif "app" in header and "user_ref" in header:
            app_col, kind, user_col, when_col = "app", "generic", "user_ref", pick(header, "last_login")
            if sso_retention is None:
                fail_input("the generic SSO file needs --sso-retention-days with the number of days the source keeps "
                           "(7 or 30 for Entra sign-ins)")
        else:
            fail_input("the SSO file is not recognised: expected Okta Application Usage columns (Instance name, Login Date_ISO8601) or app, user_ref, last_login")
        if not user_col or not when_col:
            fail_input("the SSO file needs a user column (Login or user_ref) and a login date column (Login Date_ISO8601 or last_login)")
        if sso_retention is None:
            sso_retention = 90
        people = set()
        for row in rows:
            app, user = row.get(app_col, ""), low(row.get(user_col, ""))
            if not app or not user:
                continue
            people.add(user)
            when = readable_date(row.get(when_col), args.date_format,
                                 f"SSO file {os.path.basename(args.sso)}, row {row['_row']}, column {when_col}")
            tool_id = matcher.match(app)
            bucket = sso_users[tool_id] if tool_id else sso_unmatched[app]
            if user not in bucket or (when and (bucket[user] is None or when > bucket[user])):
                bucket[user] = when
            if login_unknown(row.get(when_col)):
                sso_unclear.add((tool_id, user))
        inputs["sso"] = {"file": os.path.basename(args.sso), "format": kind, "rows": len(rows),
                         "apps": len(sso_users) + len(sso_unmatched), "people": len(people),
                         "retention_days": sso_retention}

    # ----- OAuth -----
    oauth_apps = defaultdict(list)  # tool_id -> app records
    oauth_unmatched = []
    if args.oauth:
        rows, header = read_table(args.oauth, "OAuth")
        if "app_name" not in header or "users" not in header:
            fail_input("the OAuth file is not recognised: expected the Google Workspace accessed apps columns App Name and Users")
        for row in rows:
            users = parse_number(row.get("users"))
            restricted, sensitive, inferred = classify_scopes(row.get("requested_services_with_scopes", ""),
                                                              row.get("requested_services", ""))
            app = {"name": row.get("app_name", ""), "users": int(users) if users is not None else 0,
                   "verification": row.get("verification_status", ""), "services": row.get("requested_services", ""),
                   "row": row["_row"], "restricted": restricted, "sensitive": sensitive, "inferred": inferred}
            tool_id = matcher.match(app["name"])
            if tool_id:
                oauth_apps[tool_id].append(app)
            else:
                oauth_unmatched.append(app)
        inputs["oauth"] = {"file": os.path.basename(args.oauth), "apps": len(rows)}

    # ----- seats -----
    seats = defaultdict(list)
    seat_header = []
    if args.seats:
        rows, seat_header = read_table(args.seats, "seats")
        if "tool_id" not in seat_header:
            fail_input("the seats file needs the columns of assets/seats-template.csv, starting with tool_id")
        stray = []
        for row in rows:
            if row.get("tool_id") not in tools:
                stray.append(row["_row"])
                continue
            seats[row["tool_id"]].append(row)
        if stray:
            notes.append(f"seat {rows_text(stray)} name a tool_id that is not in the register; they were skipped")
        inputs["seats"] = {"file": os.path.basename(args.seats), "rows": len(rows), "tools": len(seats)}
    if company:
        inputs["company_domains"] = company

    # ----- channels -----
    def coverage(tool_id, tool):
        found = []
        if tool_rows.get(tool_id):
            found.append("spend")
        if tool["contract_value"] is not None or tool["term_end_d"]:
            found.append("contract")
        if sso_users.get(tool_id):
            found.append("sso")
        if tool_id in oauth_apps:
            found.append("oauth")
        if seats.get(tool_id):
            found.append("seats")
        spend = defaultdict(float)
        for record in tool_rows.get(tool_id, []):
            spend[record["currency"]] += record["amount"]
        return {"name": tool["tool_name"], "channels": found, "spend": dict(spend),
                "descriptors": sorted(descriptors.get(tool_id, set()))}

    channels = {tool_id: coverage(tool_id, tool) for tool_id, tool in scope.items()}
    parked = {}  # retired and out_of_scope rows that any file still shows
    for tool_id, tool in tools.items():
        info = coverage(tool_id, tool)
        if tool_id not in scope and set(info["channels"]) - {"contract"}:
            parked[tool_id] = {"status": tool["status_l"], **info}
    merged = {}  # unregistered items merged across channels by normalised name

    def unregistered_item(display):
        key = normalise_name(display) or display
        return merged.setdefault(key, {"name": display, "channels": [], "spend": {}, "users": {}})

    for key, records in unmatched.items():
        item = unregistered_item(records[0]["vendor"])
        item["channels"].append("spend")
        for record in records:
            item["spend"][record["currency"]] = item["spend"].get(record["currency"], 0.0) + record["amount"]
    for app, users in sso_unmatched.items():
        item = unregistered_item(app)
        item["channels"].append("sso")
        item["users"]["sso"] = len(users)
    for app in oauth_unmatched:
        item = unregistered_item(app["name"])
        item["channels"].append("oauth")
        item["users"]["oauth"] = app["users"]
    unregistered = list(merged.values())

    # ----- spend summary and top share -----
    spend_summary = defaultdict(lambda: {"tool": 0.0, "register": 0.0, "unregistered": 0.0, "media": 0.0, "services": 0.0, "retired": 0.0})
    credits = defaultdict(float)
    credit_rows = 0
    for tool_id, records in tool_rows.items():
        kind = tools[tool_id]["spend_type_l"]
        for record in records:
            bucket = spend_summary[record["currency"]]
            if tool_id not in scope:
                bucket["retired"] += record["amount"]
            elif kind in APART:
                bucket[kind] += record["amount"]
            else:
                bucket["tool"] += record["amount"]
                bucket["register"] += record["amount"]
            if record["amount"] < 0:
                credits[record["currency"]] += record["amount"]
                credit_rows += 1
    for records in unmatched.values():
        for record in records:
            spend_summary[record["currency"]]["tool"] += record["amount"]
            spend_summary[record["currency"]]["unregistered"] += record["amount"]
            if record["amount"] < 0:
                credits[record["currency"]] += record["amount"]
                credit_rows += 1

    top = {}
    currencies = set(spend_summary) | {t["currency_u"] for t in scope.values() if t["contract_value"] is not None}
    for currency in sorted(currencies):
        entries = []
        for tool_id, tool in scope.items():
            if tool["spend_type_l"] in NOT_TOOL_SPEND:
                continue
            contract = tool["contract_value"] if tool["currency_u"] == currency and tool["contract_value"] else 0.0
            basis = max(actual(tool_id, currency), contract)
            if basis > 0:
                entries.append((basis, tool_id))
        for key, records in unmatched.items():
            total = sum(r["amount"] for r in records if r["currency"] == currency)
            if total > 0:
                entries.append((total, "unregistered:" + records[0]["vendor"]))
        entries.sort(key=lambda e: (-e[0], e[1]))
        grand = sum(e[0] for e in entries)
        chosen, running = [], 0.0
        for basis, key in entries:
            if running >= args.top_share * grand:
                break
            chosen.append((basis, key))
            running += basis
        if entries:
            top[currency] = {"total": grand, "covered": running, "tools": chosen}
    top_ids = {key for info in top.values() for _, key in info["tools"] if not key.startswith("unregistered:")}
    ranked = {key for info in top.values() for _, key in info["tools"]}
    unranked = [label(tools, tid) for tid, t in scope.items() if t["spend_type_l"] not in NOT_TOOL_SPEND and t["period_l"] != "monthly"
                and not t["contract_value"] and not tool_rows.get(tid) and tid not in ranked]

    # ----- INV checks -----
    if args.spend:
        in_window = sum(len(r) for r in tool_rows.values()) + sum(len(r) for r in unmatched.values())
        no_lines = "no spend line falls inside the 12-month window." if spend_info["first_date"] \
            else "no spend line has a readable date and amount."
        if report.gate(["INV01"], in_window, no_lines):
            for key, records in sorted(unmatched.items()):
                charges = [r for r in records if r["amount"] > 0]
                if not charges:
                    continue
                totals = defaultdict(float)
                for r in records:
                    totals[r["currency"]] += r["amount"]
                amount_text = ", ".join(f"{money(v)} {c}" for c, v in sorted(totals.items()))
                sources = sorted({r["source"] for r in records})
                severity = "FAIL" if len(charges) >= 2 else "WARN"
                extra = "" if severity == "FAIL" else " One charge may be annual or one-off: ask the payer."
                report.add(severity, "INV01", f'"{records[0]["vendor"]}" has {plural(len(charges), "charge")} '
                           f"in the window ({amount_text}) paid from {', '.join(sources)}, and no register row matches it.{extra}"
                           f"{matcher.hint(records[0]['vendor'], tools)}",
                           "spend " + rows_text([r["row"] for r in records]))
        if report.gate(["INV03"], in_window and source_known, no_lines if not in_window else "the spend file has no payment "
                       "source column (payment source, payment method, card name, account or source)."):
            for tool_id, records in sorted(tool_rows.items()):
                if tool_id not in scope:
                    continue
                sources = sorted({r["source"] for r in records})
                if len(sources) >= 2:
                    report.add("WARN", "INV03", f"{label(tools, tool_id)} is paid from {len(sources)} payment sources ({', '.join(sources)}). "
                               "Check for a duplicate subscription or a second payer.", "spend " + rows_text([r["row"] for r in records]))
        report.gate(["INV06"], in_window and any(t["spend_type_l"] in {"subscription", "usage"} for t in scope.values()),
                    no_lines if not in_window else "no register row records spend_type subscription or usage.")
        report.gate(["CON02"], in_window and any(t["contract_value"] for t in scope.values()),
                    no_lines if not in_window else "no register row records contract_value_annual.")
    else:
        report.not_checked(["INV01", "INV03", "CON02", "INV06"], "no spend file; supply 12 months of AP, card and expense lines with --spend.")

    if args.sso or args.oauth:
        report.gate(["INV02"], inputs.get("sso", {}).get("rows") or inputs.get("oauth", {}).get("apps"),
                    "the SSO and OAuth files hold no rows.")
        for app, users in sorted(sso_unmatched.items()):
            report.add("WARN", "INV02", f'SSO app "{app}" has {plural(len(users), "user")} signing in and no register row. '
                       f"Register it, or mark it out_of_scope with a reason.{matcher.hint(app, tools)}",
                       f"{inputs['sso']['file']}, instance {app}")
        for app in oauth_unmatched:
            if app["users"] < 1:
                continue
            status = f", {app['verification']}" if app["verification"] else ""
            who = plural(app["users"], "user")
            evidence = f"{inputs['oauth']['file']} row {app['row']}"
            hint = matcher.hint(app["name"], tools)
            if app["restricted"]:
                held = ", ".join(app["restricted"])
                if app["inferred"]:
                    also = f" (also {', '.join(app['sensitive'])})" if app["sensitive"] else ""
                    basis = f"requests {held}{also}; the export shows no scope level, so it counts as restricted"
                else:
                    also = f"; it also holds {', '.join(app['sensitive'])}" if app["sensitive"] else ""
                    basis = f"holds restricted Google scopes ({held}), which reach mail or Drive data{also}"
                report.add("FAIL", "INV02", f'OAuth app "{app["name"]}" ({who}{status}) has no register row and {basis}. '
                           f"Send it to IT security today; this audit does not revoke it.{hint}", evidence)
                act_apps.append(f'OAuth app "{app["name"]}": restricted Google scopes and no register row; IT security today (INV02)')
            else:
                kind = "sensitive services" if app["inferred"] else "sensitive scopes"
                sensitive = f"; {kind}: {', '.join(app['sensitive'])}" if app["sensitive"] else ""
                report.add("WARN", "INV02", f'OAuth app "{app["name"]}" ({who}{status}) has no register row '
                           f"(requested services: {app['services'] or 'none listed'}{sensitive}).{hint}", evidence)
    else:
        report.not_checked(["INV02"], "no SSO or OAuth file; supply an SSO app usage report (--sso) or the Google accessed apps CSV (--oauth).")

    report.gate(["INV04"], scope, "the register holds no tool in scope.")
    report.gate(["INV05"], any(low(t.get("account_owned_by")) not in UNKNOWN or t["critical_b"] for t in scope.values()),
                "no register row records account_owned_by or critical=yes.")
    personal = sum(1 for t in scope.values() if low(t.get("personal_data")) not in UNKNOWN)
    report.gate(["INV07"], personal, "no register row records personal_data (yes or no).",
                note=f"{personal} of {len(scope)} tools record personal_data" if personal < len(scope) else "")
    for tool_id, tool in scope.items():
        missing = [name for name, column in (("business", "business_owner_role"), ("technical", "technical_owner_role"))
                   if low(tool.get(column)) in UNKNOWN]
        if missing:
            severity = "FAIL" if tool["critical_b"] else "WARN"
            report.add(severity, "INV04", f"{label(tools, tool_id)} has no {' or '.join(missing)} owner role"
                       f"{' and is critical' if tool['critical_b'] else ''}.", f"tools row {tool['_row']}")
        owned = low(tool.get("account_owned_by"))
        if owned == "agency":
            severity = "FAIL" if tool["critical_b"] else "WARN"
            report.add(severity, "INV05", f"{label(tools, tool_id)} sits in an agency-owned account"
                       f"{' on a critical tool' if tool['critical_b'] else ''}. The company cannot recover it if the agency leaves.",
                       f"tools row {tool['_row']}")
        elif owned in UNKNOWN and tool["critical_b"]:
            report.add("WARN", "INV05", f"{label(tools, tool_id)} is critical and its account owner is unknown.", f"tools row {tool['_row']}")
        if low(tool.get("personal_data")) == "yes" and low(tool.get("dpa_on_file")) != "yes":
            report.add("WARN", "INV07", f"{label(tools, tool_id)} holds personal data and the DPA status is "
                       f"'{tool.get('dpa_on_file') or 'blank'}'. The privacy owner decides.", f"tools row {tool['_row']}")
        if args.spend and in_window and tool["spend_type_l"] in {"subscription", "usage"} and tool["status_l"] not in MOVING:
            if not tool_rows.get(tool_id) and not sso_users.get(tool_id) and tool_id not in oauth_apps and not seats.get(tool_id):
                report.add("WARN", "INV06", f"{label(tools, tool_id)} is an active {tool['spend_type_l']} tool with no charge, SSO user, "
                           "OAuth grant or seat row in the window. It may be free, paid by another entity, prepaid earlier or dead.",
                           f"tools row {tool['_row']}")

    # INV08: a retired row that still shows activity, so a monthly re-run catches a retirement that did not finish
    retired = sorted(tid for tid, t in tools.items() if t["status_l"] == "retired")
    if retired and report.gate(["INV08"], args.spend or args.sso or args.oauth or args.seats,
                               "no spend, SSO, OAuth or seat file to compare with the retired rows.",
                               note=plural(len(retired), "retired tool")):
        for tool_id in retired:
            tool = tools[tool_id]

            def still_active(when):
                since = tool["evidence_date_d"]
                return (as_of - when).days < args.dormant_days or (since is not None and when > since)

            charges = [r for r in tool_rows.get(tool_id, []) if r["amount"] > 0 and still_active(r["date"])]
            logins = [when for when in sso_users.get(tool_id, {}).values() if when and still_active(when)]
            granted = sum(app["users"] for app in oauth_apps.get(tool_id, []))
            enabled = [r for r in seats.get(tool_id, []) if low(r.get("status")) not in DISABLED]
            signs = []
            if charges:
                signs.append(f"{plural(len(charges), 'charge')}, the last on {max(r['date'] for r in charges)}")
            if logins:
                signs.append(f"{plural(len(logins), 'SSO user')} signing in, the last on {max(logins)}")
            if granted:
                signs.append(f"an OAuth grant for {plural(granted, 'user')}")
            if enabled:
                signs.append(plural(len(enabled), "enabled seat"))
            if signs:
                evidence = f"tools row {tool['_row']}" + (f"; spend {rows_text([r['row'] for r in charges])}" if charges else "")
                report.add("WARN", "INV08", f"{label(tools, tool_id)} is marked retired and shows {'; '.join(signs)}. "
                           "Check for a retirement that did not finish or a renewal that fired after notice.", evidence)

    # ----- CON checks -----
    report.gate(["CON01"], top, "no spend file and no contract_value_annual, so the top share of tool spend cannot be ranked.")
    for tool_id in sorted(top_ids):
        tool = tools[tool_id]
        if tool["period_l"] == "monthly":
            continue
        missing = []
        for column in CONTRACT_FIELDS:
            if column == "term_end" and not tool["term_end_d"]:
                missing.append(column)
            elif column == "notice_days" and tool["notice_n"] is None:
                missing.append(column)
            elif column not in {"term_end", "notice_days"} and low(tool.get(column)) in UNKNOWN:
                missing.append(column)
        if missing:
            share = next(f"{money(b)} {c}" for c, info in top.items() for b, k in info["tools"] if k == tool_id)
            report.add("FAIL", "CON01", f"{label(tools, tool_id)} is in the top {round(args.top_share * 100)}% of tool spend ({share}) "
                       f"and the register lacks {', '.join(missing)}. Request the executed order form.", f"tools row {tool['_row']}")
    if args.spend:
        for tool_id, tool in scope.items():
            contract, currency = tool["contract_value"], tool["currency_u"]
            if not contract or not any(r["currency"] == currency for r in tool_rows.get(tool_id, [])):
                continue
            spent = actual(tool_id, currency)
            if abs(spent - contract) > args.variance * contract:
                change = (spent - contract) / contract * 100
                report.add("WARN", "CON02", f"{label(tools, tool_id)} 12-month actual {money(spent)} {currency} is {change:+.0f}% against the "
                           f"contract value {money(contract)}. Look for overage, uplift, a second payer or a missing invoice.",
                           f"tools row {tool['_row']}; spend " + rows_text([r["row"] for r in tool_rows[tool_id]]))

    termed = [tid for tid, t in scope.items() if t["spend_type_l"] not in NOT_TOOL_SPEND and t["period_l"] != "monthly"
              and t["term_end_d"] and t["notice_n"] is not None]
    report.gate(["CON03", "CON04", "CON06"], termed, "no annual register row records both term_end and notice_days; read the executed order forms.",
                note=f"{plural(len(termed), 'tool')} with term_end and notice_days")
    report.gate(["CON05"], any(t["spend_type_l"] not in NOT_TOOL_SPEND and (t["discount_end_d"] or low(t.get("pricing_basis")) not in UNKNOWN)
                               for t in scope.values()), "no register row records discount_end or pricing_basis.")
    calendar, locked, unknown_terms, monthly = [], [], [], []
    for tool_id, tool in scope.items():
        if tool["spend_type_l"] in NOT_TOOL_SPEND:
            continue
        discount_ending = bool(tool["discount_end_d"] and as_of <= tool["discount_end_d"] <= as_of + timedelta(days=120))
        if discount_ending:
            report.add("WARN", "CON05", f"{label(tools, tool_id)} discount ends {tool['discount_end_d']} "
                       f"({(tool['discount_end_d'] - as_of).days} days). Price the renewal without it before the decision.",
                       f"tools row {tool['_row']}")
        if tool["period_l"] == "monthly":
            monthly.append(f"{label(tools, tool_id)} (reduction {tool.get('reduction_rule') or 'unknown'})")
            continue
        if not tool["term_end_d"] or tool["notice_n"] is None:
            gaps = [c for c, ok in (("term_end", tool["term_end_d"]), ("notice_days", tool["notice_n"] is not None)) if not ok]
            unknown_terms.append(f"{label(tools, tool_id)} (missing {', '.join(gaps)})")
            continue
        term_end, notice = tool["term_end_d"], tool["notice_n"]
        if term_end < as_of:
            report.add("WARN", "CON06", f"{label(tools, tool_id)} term end {term_end} is in the past: the register is stale. "
                       "Read the current order or invoice.", f"tools row {tool['_row']}")
            continue
        boundary = term_end - timedelta(days=notice)
        target = boundary - timedelta(days=args.decision_buffer)
        disposition = low(tool.get("disposition")).replace("_", "-")
        identity = bool(tool["sor_list"]) or bool(IDENTITY_CAPABILITIES & set(tool["capability_list"]))
        leverage = [reason for reason, ok in (("top spend", tool_id in top_ids), ("discount ending", discount_ending)) if ok]
        if tool["change_lead_days_n"] is not None:
            lead, why = int(tool["change_lead_days_n"]), "change_lead_days"
        elif disposition in CHANGE_DISPOSITIONS:
            lead = 120 if identity else 75
            why = disposition + (", holds identity, consent or customer-facing automation" if identity else "")
        elif disposition in REVIEW_DISPOSITIONS and leverage:
            lead, why = 75, f"{disposition}, {' and '.join(leverage)}: a tested alternative takes 75 days"
        else:
            lead, why = 30, disposition or "no disposition"
        migration = term_end - timedelta(days=lead)
        review = min(target, migration)
        entry = {"tool_id": tool_id, "tool": tool["tool_name"], "term_end": term_end.isoformat(), "notice_days": notice,
                 "notice_mechanism": tool.get("notice_mechanism") or "unknown", "boundary": boundary.isoformat(),
                 "decision_target": target.isoformat(), "migration_start": migration.isoformat(), "review_by": review.isoformat(),
                 "lead_days": lead, "lead_reason": why, "auto_renew": tool.get("auto_renew") or "unknown", "currency": tool["currency_u"]}
        if boundary < as_of:
            recert = term_end + timedelta(days=365 - 120)
            entry["recertify_by"] = recert.isoformat()
            locked.append(entry)
            if low(tool.get("auto_renew")) == "no":
                message = (f"{label(tools, tool_id)} passed its notice boundary {boundary} and auto-renew is off, so the term ends "
                           f"{term_end} unless someone renews it. Confirm the renewal or exit plan now.")
            else:
                message = (f"{label(tools, tool_id)} renewal is locked: the notice boundary {boundary} passed and the term ends {term_end}. "
                           f"Plan for the next term and recertify access, owner and need by {recert} (assumes a one-year renewal; check the order).")
            report.add("WARN", "CON03", message, f"tools row {tool['_row']}")
            continue
        if review < as_of:
            entry["bucket"] = "overdue"
            report.add("FAIL", "CON04", f"{label(tools, tool_id)} review-by date {review} has passed and the notice boundary is {boundary} "
                       f"(term ends {term_end}, notice {notice} days by {entry['notice_mechanism']}). Decide now.", f"tools row {tool['_row']}")
            act_overdue.append(f"{label(tools, tool_id)}: review-by {review} passed, notice boundary {boundary} (CON04)")
        else:
            days = (review - as_of).days
            entry["bucket"] = "0-30" if days <= 30 else "31-60" if days <= 60 else "61-90" if days <= 90 else "later"
            if days <= 14:
                act_soon.append(f"{label(tools, tool_id)}: review-by {review} ({plural(days, 'day')}), notice boundary {boundary}")
        calendar.append(entry)
    calendar.sort(key=lambda e: (e["review_by"], e["tool_id"]))
    act_soon.sort()

    # ----- USE and ACC checks -----
    enabled_seats = [(tid, r) for tid in sorted(seats) if tid in scope for r in seats[tid] if low(r.get("status")) not in DISABLED]
    usage_seen = bool(enabled_seats or sso_users)
    if args.seats:
        report.gate(["USE01", "USE03"], usage_seen, "the seats and SSO files hold no enabled account on a register tool.")
        seat_tools = [t for tid, t in scope.items() if seats.get(tid)]
        report.gate(["USE02"], any(t["paid_seats_n"] is not None for t in seat_tools) or any(low(r.get("paid_seat")) == "yes" for _, r in enabled_seats),
                    "no register row records paid_seats and no seat row has paid_seat=yes.")
        report.gate(["USE05"], any(t["paid_seats_n"] is not None for t in seat_tools), "no register row with seat rows records paid_seats.")
        report.gate(["ACC03"], any(low(r.get("email")) for _, r in enabled_seats), "no seat row records an email, so shared logins cannot be found.")
        if company:
            report.gate(["ACC02"], any(is_admin(r) for _, r in enabled_seats), "no seat row marks an enabled admin (is_admin=yes).")
        else:
            report.not_checked(["ACC01", "ACC02"], "no --company-domains, so company and external admins cannot be told apart.")
    elif args.sso:
        report.gate(["USE01", "USE03"], usage_seen, "the SSO file holds no sign-in on a register tool.")
    if not args.seats:
        report.not_checked(["USE02", "USE05", "ACC01", "ACC02", "ACC03", "ACC04", "ACC05"] + ([] if args.sso else ["USE01", "USE03"]),
                           "no seats file; normalise tool user exports into assets/seats-template.csv and pass --seats.")

    for tool_id in sorted(seats):
        if tool_id not in scope:
            continue
        tool, rows = tools[tool_id], seats[tool_id]
        enabled = [r for r in rows if low(r.get("status")) not in DISABLED]
        active, dormant, short, unclear_login, no_data = [], [], [], [], []
        for row in enabled:
            retention_text = low(row.get("source_retention_days"))
            if retention_text in NOT_APPLICABLE:
                no_data.append(row)
                continue
            retention = parse_number(retention_text)
            last = readable_date(row.get("last_login"), args.date_format,
                                 f"seats file {inputs['seats']['file']}, row {row['_row']}, column last_login")
            if last and (as_of - last).days < args.dormant_days:
                active.append(row)
            elif login_unknown(row.get("last_login")) or (retention_text and retention is None):
                unclear_login.append(row)
            elif retention is not None and retention < args.dormant_days:
                short.append(row)
            else:
                dormant.append(row)
        unknown = short + unclear_login
        for row in dormant:
            if is_admin(row):
                last = row.get("last_login") or "never"
                report.add("FAIL", "USE01", f"{label(tools, tool_id)}: an enabled admin ({row.get('role') or 'admin role'}) has no successful "
                           f"sign-in for {args.dormant_days} days or more (last login {last}). Disable or downgrade after an owner check.",
                           f"seats row {row['_row']}")
        plain = [r for r in dormant if not is_admin(r)]
        if plain:
            report.add("WARN", "USE01", f"{label(tools, tool_id)}: {plural(len(plain), 'enabled account')} with no "
                       f"successful sign-in for {args.dormant_days} days or more (source {plain[0].get('source') or 'unknown'}).",
                       "seats " + rows_text([r["_row"] for r in plain]))
        if short:
            retention = short[0].get("source_retention_days")
            report.add("WARN", "USE03", f"{label(tools, tool_id)}: {len(short)} account{'s have' if len(short) != 1 else ' has'} no login in a "
                       f"source that keeps {retention} days ({short[0].get('source') or 'unknown'}), below the {args.dormant_days}-day "
                       "threshold, so dormancy is unknown. Use the tool's own last login.", "seats " + rows_text([r["_row"] for r in short]))
        if unclear_login:
            report.add("WARN", "USE03", f"{label(tools, tool_id)}: {plural(len(unclear_login), 'account')} with an unknown last login or an unknown "
                       f"source retention ({unclear_login[0].get('source') or 'unknown'}), so dormancy is unknown. Use the tool's own last login.",
                       "seats " + rows_text([r["_row"] for r in unclear_login]))
        if enabled and len(no_data) == len(enabled):
            report.add("NOT CHECKED", "USE01", f"{label(tools, tool_id)}: the user list has no login dates (source_retention_days n/a), "
                       "so dormancy cannot be measured. Use a sign-in report for this tool.")
        paid_rows = [r for r in enabled if low(r.get("paid_seat")) == "yes"]
        paid = tool["paid_seats_n"] if tool["paid_seats_n"] is not None else (len(paid_rows) or None)
        if paid:
            active_set = {r["_row"] for r in active}
            dormant_set = {r["_row"] for r in dormant}
            unclear_set = {r["_row"] for r in unknown + no_data}
            active_paid = sum(1 for r in paid_rows if r["_row"] in active_set)
            kept = sum(1 for r in paid_rows if r["_row"] in dormant_set and low(r.get("requires_access")) == "yes")
            unclear = sum(1 for r in paid_rows if r["_row"] in unclear_set)
            reclaim = int(paid) - active_paid - kept - unclear
            if reclaim > 0:
                rule, end = low(tool.get("reduction_rule")), tool["term_end_d"]
                if rule == "any_time":
                    timing = "cash from the next billing date"
                elif rule == "at_renewal":
                    timing = f"cash from the term end {end}" if end else "cash at renewal (term end unknown)"
                elif rule == "no_reduction_in_term":
                    timing = (f"cash only from the term end {end}" if end else "cash only at renewal") + "; a smaller quantity may reprice"
                else:
                    timing = "cash timing unknown until the order form shows the reduction rule"
                report.add("WARN", "USE02", f"{label(tools, tool_id)}: {reclaim} reclaimable paid seat{'s' if reclaim != 1 else ''} = {int(paid)} paid - "
                           f"{active_paid} active - {kept} dormant but required - {unclear} unknown; {timing}.",
                           f"tools row {tool['_row']}; seats " + rows_text([r["_row"] for r in paid_rows]))
            if tool["paid_seats_n"] is not None and tool["paid_seats_n"] > len(paid_rows):
                gap = int(tool["paid_seats_n"]) - len(paid_rows)
                report.add("WARN", "USE05", f"{label(tools, tool_id)}: register shows {int(tool['paid_seats_n'])} paid seats and {len(paid_rows)} "
                           f"are assigned, so {gap} {'is' if gap == 1 else 'are'} unassigned.", f"tools row {tool['_row']}")

        admins = [r for r in enabled if is_admin(r)]
        for row in enabled:
            local = local_part(row)
            if local in GENERIC_LOCAL_PARTS:
                severity = "FAIL" if is_admin(row) else "WARN"
                report.add(severity, "ACC03", f"{label(tools, tool_id)}: a shared or generic login (local part '{local}')"
                           f"{' holds admin rights' if is_admin(row) else ''}. Replace it with named users.", f"seats row {row['_row']}")
        if company:
            company_admins = [r for r in admins if in_domains(domain_of(r), company) and local_part(r) not in GENERIC_LOCAL_PARTS]
            external = [r for r in admins if domain_of(r) and not in_domains(domain_of(r), company)]
            if tool["critical_b"]:
                report.run("ACC01")
                n = len(company_admins)
                if n == 0:
                    severity, why = "FAIL", "no company-held admin"
                elif n == 1:
                    severity = "FAIL" if tool["sor_list"] else "WARN"
                    why = "one company-held admin" + (" on a system of record" if tool["sor_list"] else "")
                elif n <= 4:
                    severity, why = "PASS", f"{n} company-held admins, inside the 2 to 4 band"
                else:
                    severity, why = "WARN", f"{n} company-held admins, above the 2 to 4 band"
                report.add(severity, "ACC01", f"{label(tools, tool_id)}: {why}.", "seats " + rows_text([r["_row"] for r in admins]) if admins else "")
            if external:
                severity = "FAIL" if not company_admins else "WARN"
                domains = ", ".join(sorted({domain_of(r) for r in external}))
                report.add(severity, "ACC02", f"{label(tools, tool_id)}: {plural(len(external), 'enabled admin')} from "
                           f"an external domain ({domains}){' and no company-held admin' if not company_admins else ''}.",
                           "seats " + rows_text([r["_row"] for r in external]))

    if args.seats and company:
        for tool_id, tool in sorted(scope.items()):
            if tool["critical_b"] and not seats.get(tool_id):
                report.add("NOT CHECKED", "ACC01", f"{label(tools, tool_id)} is critical and has no seat rows; export its user list with roles.")

    if args.seats:
        # ACC04: admins on critical tools without enforced MFA or SSO
        critical_admins = defaultdict(list)
        for tid, r in enabled_seats:
            if tools[tid]["critical_b"] and is_admin(r):
                critical_admins[tid].append(r)
        if "mfa" not in seat_header:
            report.not_checked(["ACC04"], "the seats file has no mfa column; record enforced, optional or unknown per account "
                                          "from each critical tool's security settings.")
        elif report.gate(["ACC04"], critical_admins, "no seat row holds an enabled admin on a critical tool."):
            unknown_admins = defaultdict(list)
            for tid, admins in sorted(critical_admins.items()):
                weak = [r for r in admins if mfa_state(r) == "not_enforced"]
                unclear = [r for r in admins if mfa_state(r) == "unknown"]
                if weak:
                    sor = bool(tools[tid]["sor_list"])
                    more = f"; MFA is unknown for {len(unclear)} more" if unclear else ""
                    report.add("FAIL" if sor else "WARN", "ACC04", f"{label(tools, tid)}: {plural(len(weak), 'enabled admin')} can sign in "
                               f"without enforced MFA or SSO{' on a system of record' if sor else ''}{more}. Require MFA for every admin, "
                               "after shared admin logins are replaced with named users.", "seats " + rows_text([r["_row"] for r in weak]))
                elif unclear:
                    unknown_admins[tid] = unclear
            if unknown_admins:
                count = sum(len(v) for v in unknown_admins.values())
                report.add("NOT CHECKED", "ACC04", f"MFA status is unknown for {plural(count, 'enabled admin')} on "
                           f"{', '.join(label(tools, t) for t in unknown_admins)}. Record it from each tool's security settings, "
                           "or from the admin's own organisation for external admins.",
                           "seats " + rows_text([r["_row"] for v in unknown_admins.values() for r in v]))

        # ACC05: enabled tool accounts whose identity provider user is deactivated
        if "idp_status" not in seat_header:
            report.not_checked(["ACC05"], "the seats file has no idp_status column; join each tool export to the identity "
                                          "provider's user list and record active, deactivated or not_in_idp.")
        else:
            known = [(tid, r) for tid, r in enabled_seats if low(r.get("idp_status")) not in UNKNOWN]
            blank = [(tid, r) for tid, r in enabled_seats if low(r.get("idp_status")) in BLANK]
            if report.gate(["ACC05"], known, "no seat row records idp_status."):
                gone, orphan = defaultdict(list), defaultdict(list)
                for tid, r in known:
                    state = low(r.get("idp_status"))
                    if state in IDP_GONE:
                        gone[tid].append(r)
                    elif state in IDP_ABSENT and company and in_domains(domain_of(r), company) and local_part(r) not in GENERIC_LOCAL_PARTS:
                        orphan[tid].append(r)
                for tid, rows in sorted(gone.items()):
                    one = len(rows) == 1
                    report.add("FAIL", "ACC05", f"{label(tools, tid)}: {plural(len(rows), 'enabled account')} whose identity provider user "
                               f"{'is' if one else 'are'} deactivated. Check what {'it owns' if one else 'each owns'} (workflows, integrations, "
                               f"reports, API keys), move that to a named owner, then disable {'it' if one else 'them'}.",
                               "seats " + rows_text([r["_row"] for r in rows]))
                for tid, rows in sorted(orphan.items()):
                    report.add("WARN", "ACC05", f"{label(tools, tid)}: {plural(len(rows), 'enabled company-domain account')} with no identity "
                               "provider user: a deleted user or a local account. Confirm who holds it.",
                               "seats " + rows_text([r["_row"] for r in rows]))
                if blank:
                    report.add("NOT CHECKED", "ACC05", f"idp_status is blank or unknown for {plural(len(blank), 'enabled account')} on "
                               f"{', '.join(label(tools, t) for t in sorted({tid for tid, _ in blank}))}.",
                               "seats " + rows_text([r["_row"] for _, r in blank]))

    if args.sso:
        short = []
        for tool_id, users in sorted(sso_users.items()):
            if seats.get(tool_id) or tool_id not in scope:
                continue
            if sso_retention < args.dormant_days:
                report.add("WARN", "USE03", f"{label(tools, tool_id)}: the SSO report keeps {sso_retention} days, below the "
                           f"{args.dormant_days}-day threshold, so dormancy is unknown. Use the tool's own last login.", inputs["sso"]["file"])
                short.append(tool_id)
                continue
            stale = [u for u, when in users.items() if ((tool_id, u) not in sso_unclear if when is None
                                                         else (as_of - when).days >= args.dormant_days)]
            if stale:
                report.add("WARN", "USE01", f"{label(tools, tool_id)}: {len(stale)} of {len(users)} SSO users last signed in "
                           f"{args.dormant_days} days ago or more (SSO report; people who never sign in do not appear in it).",
                           inputs["sso"]["file"])
        if short:
            report.add("NOT CHECKED", "USE01", f"dormancy is not measured for {', '.join(label(tools, t) for t in short)}: their only "
                       f"login source keeps {sso_retention} days, below the {args.dormant_days}-day threshold.")

    capacity = [tid for tid, t in scope.items() if t["measured_peak_units_n"] is not None and t["next_lower_tier_units_n"] is not None]
    report.gate(["USE04"], capacity, "no register row records both measured_peak_units and next_lower_tier_units.",
                note=f"{plural(len(capacity), 'tool')} with measured_peak_units and next_lower_tier_units")
    growth_text = f"growth {args.growth:.0%}" + (" assumed; pass --growth with the owner's forecast" if args.growth == 0 else " from --growth")
    for tool_id in capacity:
        tool = scope[tool_id]
        peak, lower = tool["measured_peak_units_n"], tool["next_lower_tier_units_n"]
        if peak * (1 + args.growth) <= lower:
            committed = tool["committed_units_n"]
            report.add("WARN", "USE04", f"{label(tools, tool_id)}: 12-month peak {peak:,.0f} x {1 + args.growth:.2f} ({growth_text}) = "
                       f"{peak * (1 + args.growth):,.0f} fits the next lower tier of {lower:,.0f}"
                       f"{f' (committed {committed:,.0f})' if committed else ''}. Right-size at the next reduction point.",
                       f"tools row {tool['_row']}")

    # ACC06: registered apps holding restricted Google scopes
    if args.oauth:
        report.gate(["ACC06"], inputs["oauth"]["apps"], "the OAuth file holds no rows.")
        for tool_id in sorted(oauth_apps):
            if tool_id not in scope:
                continue
            for app in oauth_apps[tool_id]:
                if app["users"] < 1 or not app["restricted"]:
                    continue
                held = ", ".join(app["restricted"])
                who = plural(app["users"], "user")
                what = (f"requests {held} for {who}; the export shows no scope level, so it counts as restricted" if app["inferred"]
                        else f"holds restricted Google scopes ({held}) for {who}")
                narrower = " (for example drive.file instead of drive)" if any(s.startswith("drive") for s in app["restricted"]) else ""
                report.add("WARN", "ACC06", f'{label(tools, tool_id)}: the Google grant "{app["name"]}" {what}. The owner confirms '
                           f"the need, who needs it and whether a narrower scope would do{narrower}.",
                           f"{inputs['oauth']['file']} row {app['row']}")
    else:
        report.not_checked(["ACC06"], "no OAuth file; supply the Google Workspace accessed apps CSV with --oauth.")

    # ----- OVL checks -----
    with_caps = [tid for tid, t in scope.items() if t["capability_list"]]
    with_actions = [tid for tid, t in scope.items() if t["actions_list"]]
    with_sor = [tid for tid, t in scope.items() if t["sor_list"]]
    report.gate(["OVL01"], len(with_caps) >= 2, "fewer than two register rows record capabilities.",
                note=f"{len(with_caps)} of {len(scope)} tools record capabilities" if len(with_caps) < len(scope) else "")
    report.gate(["OVL02"], with_actions, "no register row records customer_actions.",
                note=f"{len(with_actions)} of {len(scope)} tools record customer_actions")
    report.gate(["OVL03"], with_sor, "no register row records system_of_record_for.",
                note=f"{len(with_sor)} of {len(scope)} tools record system_of_record_for")
    by_capability, by_action, by_authority = defaultdict(list), defaultdict(list), defaultdict(list)
    for tool_id, tool in scope.items():
        for capability in tool["capability_list"]:
            by_capability[capability].append(tool_id)
        for action in tool["actions_list"]:
            by_action[action].append(tool_id)
        for item in tool["sor_list"]:
            by_authority[item].append(tool_id)
    for capability, ids in sorted(by_capability.items()):
        live = [i for i in ids if tools[i]["status_l"] not in MOVING]
        moving = [i for i in ids if i not in live]
        if len(live) + len(moving) < 2:
            continue
        names = ", ".join(label(tools, i) for i in ids)
        evidence = "tools " + rows_text([tools[i]["_row"] for i in ids])
        if len(live) <= 1 and all(low(tools[i].get("disposition")) not in UNKNOWN for i in moving):
            report.add("INFO", "OVL01", f"{capability} is shared by {names} during a migration; confirm the end date and owner, then rate it PASS.", evidence)
        else:
            report.add("WARN", "OVL01", f"{capability} is shared by {names}. Compare the jobs, population, region and data grain before any decision.", evidence)
    for action, ids in sorted(by_action.items()):
        if len(ids) >= 2:
            report.add("FAIL", "OVL02", f"customer action {action} is run by {', '.join(label(tools, i) for i in ids)}: "
                       "the same people can get the same message or task twice.", "tools " + rows_text([tools[i]["_row"] for i in ids]))
    for item, ids in sorted(by_authority.items()):
        if len(ids) >= 2:
            report.add("FAIL", "OVL03", f"{item} has two systems of record: {', '.join(label(tools, i) for i in ids)}.",
                       "tools " + rows_text([tools[i]["_row"] for i in ids]))

    report.finish()
    counts = report.counts()
    summary_line = (f"{counts['FAIL']} FAIL, {counts['WARN']} WARN, {counts['PASS']} PASS, {counts['NOT CHECKED']} NOT CHECKED"
                    + (f", {counts['INFO']} INFO" if counts["INFO"] else "") + ". Nothing was changed.")
    act_now = act_overdue + act_soon + act_apps

    if args.json:
        output = {"as_of": as_of.isoformat(), "window": [(window_start + timedelta(days=1)).isoformat(), as_of.isoformat()],
                  "inputs": inputs, "notes": notes, "act_now": [mask(a) for a in act_now], "channels": channels,
                  "retired_or_out_of_scope": parked, "unregistered": unregistered,
                  "spend": {c: {k: round(v, 2) for k, v in s.items()} for c, s in spend_summary.items()},
                  "top_share": {c: {"total": round(i["total"], 2), "covered": round(i["covered"], 2),
                                    "tools": [{"key": k, "basis": round(b, 2)} for b, k in i["tools"]]} for c, i in top.items()},
                  "not_ranked": unranked,
                  "renewals": {"calendar": calendar, "locked": locked, "unknown_terms": unknown_terms, "monthly": monthly},
                  "findings": [f for f in report.findings if f["severity"] != "NOT CHECKED"],
                  "not_checked": [f for f in report.findings if f["severity"] == "NOT CHECKED"],
                  "summary": {**counts, "line": summary_line}}
        print(mask(json.dumps(output, indent=2, default=str)))
    else:
        out = [f"Martech stack inventory as of {as_of} (read-only; reads local files, writes nothing)", "", "Inputs"]
        out.append(f"  tools: {inputs['tools']['file']}, {len(tools)} rows, {len(scope)} in scope")
        if spend_info:
            dated = f" dated {spend_info['first_date']} to {spend_info['last_date']}" if spend_info["first_date"] else ", no readable dates"
            out.append(f"  spend: {spend_info['file']}, {spend_info['rows']} rows{dated}; "
                       f"window {window_start + timedelta(days=1)} to {as_of}; {spend_info['outside_window']} rows outside the window"
                       + (f"; unreadable date or amount in {spend_info['unreadable_rows']}" if spend_info["unreadable_rows"] else ""))
        if "sso" in inputs:
            s = inputs["sso"]
            out.append(f"  sso: {s['file']} ({s['format']}), {s['rows']} rows, {s['apps']} apps, {s['people']} people; retention {s['retention_days']} days")
        if "oauth" in inputs:
            out.append(f"  oauth: {inputs['oauth']['file']} (Google Workspace accessed apps), {inputs['oauth']['apps']} apps")
        if "seats" in inputs:
            out.append(f"  seats: {inputs['seats']['file']}, {inputs['seats']['rows']} rows across {inputs['seats']['tools']} tools")
        if company:
            out.append(f"  company domains: {', '.join(company)}")
        out.append("  Row numbers count the header as row 1.")
        for note in notes:
            out.append(f"  Note: {mask(note)}")

        out += ["", "Act now (tell the user before the remaining steps)"]
        out += [f"  {mask(item)}" for item in act_now] or ["  nothing"]

        out += ["", "Channel coverage (spend, contract fields, sso, oauth, seats), 12-month spend and the spend descriptors matched to each tool"]

        def coverage_line(tool_id, info, prefix=""):
            spend = "; ".join(f"{money(v)} {c}" for c, v in sorted(info["spend"].items())) or "no spend"
            matched = ""
            if info["descriptors"]:
                more = f" and {len(info['descriptors']) - 5} more" if len(info["descriptors"]) > 5 else ""
                matched = f"; descriptors: {', '.join(mask(d) for d in info['descriptors'][:5])}{more}"
            return f"  {prefix}{tool_id} {info['name']}: {', '.join(info['channels']) or 'no channel'}; {spend}{matched}"

        out += [coverage_line(tool_id, info) for tool_id, info in channels.items()]
        out += [coverage_line(tool_id, info, info["status"] + " ") for tool_id, info in parked.items()]
        for item in unregistered:
            detail = [f"{money(v)} {c}" for c, v in sorted(item["spend"].items())]
            detail += [f"{n} {channel} users" for channel, n in item["users"].items()]
            out.append(f"  unregistered {mask(item['name'])}: {', '.join(item['channels'])}; {'; '.join(detail)}")
        single = [f"{t} ({', '.join(i['channels'])})" for t, i in channels.items() if len(i["channels"]) == 1]
        single += [f"{mask(u['name'])} ({u['channels'][0]}, unregistered)" for u in unregistered if len(u["channels"]) == 1]
        none = [f"{t} {i['name']}" for t, i in channels.items() if not i["channels"]]
        out.append(f"  Seen in one channel only: {', '.join(single) or 'none'}")
        out.append(f"  Seen in no channel: {', '.join(none) or 'none'}")

        if spend_summary:
            out += ["", f"12-month spend {window_start + timedelta(days=1)} to {as_of}, per currency (currencies are never added together)"]
            for currency, s in sorted(spend_summary.items()):
                apart = "; ".join(f"{name} {money(s[k])}" for k, name in APART_LABELS if s[k])
                out.append(f"  {currency} tool spend {money(s['tool'])} (register tools {money(s['register'])}, unregistered {money(s['unregistered'])})"
                           + (f"; reported apart: {apart}" if apart else ""))
            if credit_rows:
                out.append(f"  Credits included: {credit_rows} row{'s' if credit_rows != 1 else ''}, "
                           + ", ".join(f"{money(v)} {c}" for c, v in sorted(credits.items())))
        if top or unranked:
            out += ["", f"Top {round(args.top_share * 100)}% of tool spend per currency (cost basis: larger of 12-month actual and contract value)"]
            for currency, info in top.items():
                names = "; ".join(f"{label(tools, k) if not k.startswith('unregistered:') else mask(k)} {money(b)}" for b, k in info["tools"])
                share = info["covered"] / info["total"] * 100 if info["total"] else 0
                count = len(info["tools"])
                out.append(f"  {currency}: {count} {'tool covers' if count == 1 else 'tools cover'} {money(info['covered'])} of "
                           f"{money(info['total'])} ({share:.1f}%): {names}")
        if unranked:
            out.append(f"  Not ranked (no contract value and no spend in the window): {', '.join(unranked)}")

        out += ["", f"Renewal calendar by review-by date (calendar days; decision buffer {args.decision_buffer} days; "
                    "the contract owner confirms counting and valid notice)"]
        for e in calendar:
            out.append(f"  {e['review_by']} {e['bucket']:<7} {e['tool_id']} {e['tool']}: term ends {e['term_end']}, notice {e['notice_days']} days "
                       f"({e['notice_mechanism']}), boundary {e['boundary']}, decision target {e['decision_target']}, migration start "
                       f"{e['migration_start']} (lead {e['lead_days']}, {e['lead_reason']}), auto-renew {e['auto_renew']}, {e['currency']}")
        for e in locked:
            out.append(f"  Locked: {e['tool_id']} {e['tool']}: boundary {e['boundary']} passed, term ends {e['term_end']}; recertify by {e['recertify_by']}")
        out.append(f"  Unknown terms: {'; '.join(unknown_terms) or 'none'}")
        out.append(f"  Monthly: {'; '.join(monthly) or 'none'}")

        out += ["", "Findings"]
        for f in report.findings:
            evidence = f" Evidence: {f['evidence']}." if f["evidence"] else ""
            out.append(f"{f['severity']} {f['check']} {f['name']}: {f['message']}{evidence}")
        out += ["", summary_line]
        print("\n".join(out))
    return 1 if counts["FAIL"] else 0


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