#!/usr/bin/env python3
"""Check a martech connection register and print a Mermaid stack map.

Usage (run from the audit folder, by the script's path in the skill folder):
  python3 <skill folder>/scripts/connection_map.py --tools TOOLS.csv
      --connections CONNECTIONS.csv [--vocabulary FILE] [--as-of YYYY-MM-DD]
      [--stale-days 2] [--mermaid-only] [--json]

Inputs (CSV with comma, semicolon or tab separators; UTF-8 or Latin-1):
  --tools        the register, with the columns of assets/tool-register-template.csv
  --connections  one row per directional transfer, assets/connections-template.csv
  --vocabulary   capability to layer map (default assets/capability-vocabulary.csv)

A connection is critical when its source, destination or via tool has
critical=yes. Rows with status proposed are drawn dotted and are not checked.
A tool counts unless its status is retired or out_of_scope; a Note lists rows
with any other word. Judgement columns holding unknown, tbc, tbd, ? or none
count as blank; n/a is a deliberate value. last_success and checked_at are
YYYY-MM-DD; a date that cannot be read stops the run and names the row and column.

Checks: FLW01 two-writers, FLW02 stale, FLW03 email-only-key,
FLW04 no-alert-or-replay, FLW05 person-credential, FLW06 observed-without-evidence,
FLW07 hidden-rejects, FLW08 no-consent-rule, FLW09 unregistered-system,
FLW10 manual-step, FLW11 unmapped-critical.
A check whose input columns are blank in every row prints NOT CHECKED, never PASS.
Rules: references/flows-identity-and-data-quality.md.

Output: findings; a PASS line for each clean check and for each clean connection
that is observed with an evidence reference and a check date; NOT CHECKED for a
clean connection that is only configured; a Mermaid flowchart grouped by layer
(edges observed with dated evidence solid, all others dotted, FAIL check IDs at
the start of edge labels) and a summary line.
--mermaid-only prints the diagram alone, without code fences.

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

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"}
DOCUMENTED_STATUS = {"active", "trial", "migrating", "retiring", "retired", "out_of_scope"}
UNKNOWN = {"", "unknown", "tbc", "tbd", "?", "none"}
NOT_APPLICABLE = {"n/a", "na"}
CREDENTIAL_OWNERS = {"service_account", "person:active", "person:departed", "manual", "n/a", "na"}
ACTION_FEEDS = {"customer_action", "billing"}
MESSAGING_OR_ADS = {"lifecycle_messaging", "marketing_automation_email", "sms", "push", "in_app_messaging", "ad_platform"}
PEOPLE_RE = re.compile(r"\b(people|persons?|contacts?|users?|leads?|subscribers?|members?|customers?|recipients?|profiles?"
                       r"|identif(?:y|ies|iers?)|traits?|audiences?|segments?|emails?|phones?|unsubscribes?|customer lists?)\b",
                       re.IGNORECASE)
NOT_PEOPLE_RE = re.compile(r"\b(reporting tables?|products|catalogs?|catalogues?|invoices?)\b", re.IGNORECASE)
EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
LAYER_ORDER = ["collect", "store", "model", "measure", "activate", "engage", "support", "ai", "other"]
SEVERITY_ORDER = ["FAIL", "WARN", "PASS", "NOT CHECKED"]
CHECKS = {
    "FLW01": "two-writers", "FLW02": "stale", "FLW03": "email-only-key", "FLW04": "no-alert-or-replay",
    "FLW05": "person-credential", "FLW06": "observed-without-evidence", "FLW07": "hidden-rejects",
    "FLW08": "no-consent-rule", "FLW09": "unregistered-system", "FLW10": "manual-step", "FLW11": "unmapped-critical",
}
PASS_TEXT = {
    "FLW01": "no field has two writers without an authority rule",
    "FLW02": "no flow is slower than its required freshness or older than its schedule allows",
    "FLW03": "no connection matches on email alone",
    "FLW04": "every critical connection has an alert owner and a replay method",
    "FLW05": "no connection runs on a person's credential where it matters",
    "FLW06": "every observed connection has an evidence reference and a check date",
    "FLW07": "every critical connection shows its rejected records",
    "FLW08": "every people flow into a messaging or ad tool names its consent or suppression rule",
    "FLW09": "every system in the connection register is in the tool register",
    "FLW10": "no critical connection depends on a manual CSV step",
    "FLW11": "every critical tool appears in at least one mapped connection",
}
RESERVED_IDS = {"end", "graph", "subgraph", "flowchart", "style", "class", "classdef", "click", "linkstyle", "direction"}


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 low(value):
    return (value or "").strip().lower()


def known(value):
    """False for blank and the words that mean nobody knows. n/a stays a deliberate value."""
    return low(value) not in UNKNOWN


def recorded(value):
    """True only for a real value: for evidence and field authority, n/a says nothing."""
    return known(value) and low(value) not in NOT_APPLICABLE


def norm_word(value):
    return re.sub(r"[\s-]+", "_", low(value))


def status_note(statuses):
    """The Note for tool rows whose status is not a documented value, or an empty string."""
    odd = defaultdict(list)
    for tool_id, status in statuses.items():
        if status not in DOCUMENTED_STATUS:
            odd[status or "blank"].append(tool_id)
    if not odd:
        return ""
    return "status outside the documented values, kept in scope: " + "; ".join(
        f"{word} ({', '.join(ids)})" for word, ids in sorted(odd.items()))


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


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")
    counts = {d: first.count(d) for d in (",", ";", "\t")}
    delimiter = max(counts, key=lambda d: (counts[d], d == ","))
    reader = csv.reader(io.StringIO(text), delimiter=delimiter if counts[delimiter] else ",")
    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 split_multi(value):
    return [part.strip() for part in (value or "").split(";") if part.strip()]


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 recorded(item)]


def number(value):
    text = re.sub(r"[^0-9.]", "", value or "")
    try:
        return float(text) if text else None
    except ValueError:
        return None


def parse_date(value):
    try:
        return date.fromisoformat((value or "").strip()[:10])
    except ValueError:
        return None


def column_date(row, column):
    """A date cell: None when blank, unknown or n/a; a filled cell that cannot be read stops the run."""
    value = (row.get(column) or "").strip()
    if not recorded(value):
        return None
    parsed = parse_date(value)
    if parsed is None:
        fail_input(f"connections row {row['_row']}, column {column}: cannot read the date '{value}'. "
                   "Write YYYY-MM-DD, or leave it blank.")
    return parsed


def names_no_people(text):
    """True only when every part of the wording is on the explicit non-person list."""
    parts = [part for part in re.split(r"[;,]| and ", text) if part.strip()]
    return bool(parts) and all(NOT_PEOPLE_RE.search(part) for part in parts)


def interval_days(schedule):
    """Days between scheduled runs; 0 for realtime; None when there is no schedule to judge."""
    text = low(schedule).replace("_", " ").replace("-", " ")
    if not text or text in {"on demand", "manual", "ad hoc", "n/a"}:
        return None
    if text in {"realtime", "real time", "streaming", "event", "webhook"}:
        return 0.0
    match = re.match(r"every (\d+) (minute|hour|day|week)s?", text)
    if match:
        return int(match.group(1)) * {"minute": 1 / 1440, "hour": 1 / 24, "day": 1.0, "week": 7.0}[match.group(2)]
    return {"hourly": 1 / 24, "daily": 1.0, "weekly": 7.0, "monthly": 31.0}.get(text)


def rows_text(numbers):
    numbers = sorted(set(numbers))
    return ("row " if len(numbers) == 1 else "rows ") + ", ".join(str(n) for n in numbers)


def sanitise(text):
    """Mermaid-safe label text: no quotes, pipes, brackets, semicolons or angle brackets."""
    text = re.sub(r"[\"'`|<>{}\[\]()#;]", " ", mask(text))
    return re.sub(r"\s+", " ", text).strip()


def node_id(value):
    ident = re.sub(r"[^A-Za-z0-9_]", "_", value)
    return f"n_{ident}" if not ident or ident[0].isdigit() or ident.lower() in RESERVED_IDS else ident


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

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

    def gate(self, checks, present, reason):
        """Run the checks only when their input columns hold data; otherwise NOT CHECKED, never PASS."""
        if present:
            self.ran.update(checks)
        else:
            for check in checks:
                self.add("NOT CHECKED", check, 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:
                self.add("PASS", check, PASS_TEXT[check] + ".")
        rank = {check: i for i, check in enumerate(CHECKS)}
        self.findings.sort(key=lambda f: (SEVERITY_ORDER.index(f["severity"]), rank.get(f["check"], 99), f["check"]))

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


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 load_tools(path):
    rows, header = read_table(path, "tools")
    if "tool_id" not in header or "tool_name" not in header:
        fail_input("the tools file needs tool_id and tool_name columns (see assets/tool-register-template.csv)")
    tools = {}
    for row in rows:
        if row.get("tool_id"):
            tools[row["tool_id"]] = {
                "name": row.get("tool_name", ""), "row": row["_row"], "status": norm_word(row.get("status")),
                "critical": low(row.get("critical")) == "yes", "owner": row.get("business_owner_role", ""),
                "capabilities": listed(row.get("capabilities")),
                "sor": listed(row.get("system_of_record_for")),
            }
    return tools


def main():
    parser = argparse.ArgumentParser(description="Check a martech connection register and draw a Mermaid stack map. Read-only.")
    parser.add_argument("--tools", required=True)
    parser.add_argument("--connections", required=True)
    parser.add_argument("--vocabulary", default=DEFAULT_VOCABULARY)
    parser.add_argument("--as-of")
    parser.add_argument("--stale-days", type=float, default=2.0)
    parser.add_argument("--mermaid-only", action="store_true")
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()
    try:
        as_of = date.fromisoformat(args.as_of) if args.as_of else date.today()
    except ValueError:
        fail_input(f"--as-of must be YYYY-MM-DD, got {args.as_of}")

    vocabulary = load_vocabulary(args.vocabulary)
    tools = load_tools(args.tools)
    rows, header = read_table(args.connections, "connections")
    for column in ("connection_id", "source_id", "destination_id"):
        if column not in header:
            fail_input(f"the connections file needs a {column} column (see assets/connections-template.csv)")

    conns = []
    for row in rows:
        c = dict(row)
        c["id"] = row.get("connection_id") or f"row {row['_row']}"
        c["status_l"] = low(row.get("status")) or "configured"
        c["fields"] = listed(row.get("fields_written"))
        c["required"] = number(row.get("required_freshness_minutes"))
        c["observed"] = number(row.get("observed_delay_minutes"))
        c["last"] = column_date(row, "last_success")
        c["checked"] = column_date(row, "checked_at")
        c["dated"] = c["status_l"] == "observed" and recorded(row.get("evidence_ref")) and c["checked"] is not None
        c["ends"] = [x for x in (row.get("source_id"), row.get("destination_id"), row.get("via_id")) if x]
        conns.append(c)
    live = [c for c in conns if c["status_l"] != "proposed"]
    critical = {tid for tid, t in tools.items() if t["critical"] and t["status"] not in OUT_OF_SCOPE}
    note = status_note({tid: t["status"] for tid, t in tools.items()})
    notes = [note] if note else []

    def is_critical(c):
        return any(end in critical for end in c["ends"])

    def name(tid):
        return f"{tid} {tools[tid]['name']}" if tid in tools else f"{tid} (not in the register)"

    def path(c):
        via = f" via {name(c['via_id'])}" if c.get("via_id") else ""
        return f"{c['id']} {name(c.get('source_id', ''))} -> {name(c.get('destination_id', ''))}{via}"

    report = Report()
    flagged = defaultdict(set)
    fails = defaultdict(set)

    def flag(severity, check, c, message):
        flagged[c["id"]].add(check)
        if severity == "FAIL":
            fails[c["id"]].add(check)
        report.add(severity, check, f"{path(c)}: {message}", f"connections row {c['_row']}")

    critical_conns = [c for c in live if is_critical(c)]
    report.gate(["FLW01"], any(c["fields"] for c in live), "no connection records fields_written.")
    report.gate(["FLW03"], any(known(c.get("match_key")) for c in live), "no connection records match_key.")
    report.gate(["FLW04", "FLW07"], critical_conns, "no connection touches a tool marked critical=yes.")
    report.gate(["FLW05"], critical_conns or any(known(c.get("credential_owner")) for c in live),
                "no connection records credential_owner and none touches a critical tool.")
    report.gate(["FLW06"], any(c["status_l"] == "observed" for c in live),
                "no connection is marked observed; every connection counts as configured until a dated test exists.")
    report.gate(["FLW08"], any(known(c.get("objects_or_events")) and tools.get(c.get("destination_id", ""), {}).get("capabilities") for c in live),
                "no connection records objects_or_events with a destination whose capabilities are recorded.")
    report.gate(["FLW09"], live, "no configured or observed connection to check.")
    report.gate(["FLW10"], any(known(c.get("method")) for c in critical_conns), "no connection to a critical tool records its method.")
    report.gate(["FLW11"], critical, "no tool is marked critical=yes.")

    # FLW01: two or more writers of one destination field without an authority rule.
    # A system of record counts as a writer only when it is the destination: a copy out of it has one writer.
    # That entry is also an authority claim, so a connection claiming source_wins against it makes two authorities.
    writers = defaultdict(list)
    for c in live:
        for field in c["fields"]:
            writers[(c.get("destination_id", ""), field)].append(c)
    for (destination, field), writing in sorted(writers.items()):
        systems = [destination] if destination in tools and field in tools[destination]["sor"] else []
        total = len(writing) + len(systems)
        blank = [c for c in writing if not recorded(c.get("field_authority"))]
        claims = [c for c in writing if norm_word(c.get("field_authority")) == "source_wins"]
        if total >= 2 and (blank or len(claims) >= 2 or (systems and claims)):
            others = [w["id"] for w in writing] + [f"{name(s)} (system of record)" for s in systems]
            if blank:
                reason = "with no field_authority rule"
            elif systems:
                reason = (f"and {', '.join(c['id'] for c in claims)} claims source_wins on a field {name(destination)} "
                          "is recorded as the system of record for: two authorities")
            else:
                reason = "and each connection claims source_wins"
            for c in (blank or claims):
                flag("FAIL", "FLW01", c, f"{field} in {name(destination)} has {total} writers ({', '.join(others)}) {reason}. "
                     "Name one authority or split the field.")

    # FLW02: slower than required, or no recent success for the schedule
    freshness_known = False
    for c in live:
        feeds = low(c.get("feeds"))
        if c["required"] is not None and c["observed"] is not None:
            freshness_known = True
            if c["observed"] > c["required"]:
                severity = "FAIL" if feeds in ACTION_FEEDS else "WARN"
                flag(severity, "FLW02", c, f"observed delay {c['observed']:,.0f} minutes is over the required {c['required']:,.0f} "
                     f"minutes and the flow feeds {feeds or 'an unstated use'}.")
        interval = interval_days(c.get("schedule"))
        if c["status_l"] == "observed" and interval is not None and c["last"]:
            freshness_known = True
            limit = max(args.stale_days, 2 * interval)
            age = (as_of - c["last"]).days
            if age > limit:
                severity = "FAIL" if feeds in ACTION_FEEDS else "WARN"
                flag(severity, "FLW02", c, f"last success {c['last']} is {age} days old, over the {limit:g}-day limit for a "
                     f"{low(c.get('schedule'))} schedule.")
    if freshness_known:
        report.ran.add("FLW02")
    else:
        report.add("NOT CHECKED", "FLW02", "no connection records required and observed freshness, or a schedule with a last success.")

    unregistered = defaultdict(list)
    for c in live:
        crit = is_critical(c)
        keys = [k for k in re.split(r"[;,+]| and ", low(c.get("match_key"))) if k.strip()]
        if keys and all("email" in k for k in keys):
            flag("WARN", "FLW03", c, f"matches on email only ({c.get('match_key')}). Shared or changed addresses merge or split records; add an internal ID.")
        if crit:
            blanks = [label for label, column in (("alert owner", "alert_owner_role"), ("replay method", "replay_method")) if not known(c.get(column))]
            if blanks:
                flag("WARN", "FLW04", c, f"touches a critical tool and has no {' or '.join(blanks)}.")
        credential = low(c.get("credential_owner"))
        if credential == "person:departed":
            flag("FAIL", "FLW05", c, "runs on the credential of a person who has left. Rebuild it under a service identity with a named human owner.")
        elif credential == "person:active" and crit:
            flag("WARN", "FLW05", c, "runs on an active person's credential on a critical connection; it breaks when they leave.")
        elif not known(credential) and crit:
            flag("WARN", "FLW05", c, "has no recorded credential owner on a critical connection.")
        elif credential not in CREDENTIAL_OWNERS and crit:
            flag("WARN", "FLW05", c, "records a credential owner outside the documented values (service_account, person:active, "
                 "person:departed, manual, n/a) on a critical connection.")
        if c["status_l"] == "observed" and not c["dated"]:
            flag("WARN", "FLW06", c, "is marked observed without an evidence_ref and checked_at. Treat it as configured until a dated test is attached.")
        visible = low(c.get("failed_records_visible"))
        if crit and visible != "yes" and visible not in NOT_APPLICABLE:
            flag("WARN", "FLW07", c, "does not show rejected records to anyone; failures stay silent." if visible == "no"
                 else "does not record whether rejected records are visible.")
        destination = tools.get(c.get("destination_id", ""))
        if destination and MESSAGING_OR_ADS & set(destination["capabilities"]) and not known(c.get("consent_or_suppression_rule")):
            objects = c.get("objects_or_events", "") if known(c.get("objects_or_events")) else ""
            wording = objects.replace("_", " ")
            if PEOPLE_RE.search(wording):
                flag("WARN", "FLW08", c, f"sends people data ({objects}) to a messaging or ad tool with no consent or suppression rule recorded.")
            elif not names_no_people(wording):
                flagged[c["id"]].add("FLW08")
                report.add("NOT CHECKED", "FLW08", f"{path(c)}: objects_or_events ({c.get('objects_or_events') or 'blank'}) does not say whether people data reaches "
                           "this messaging or ad tool, and no consent or suppression rule is recorded. Record the rule, or n/a when no people data moves.",
                           f"connections row {c['_row']}")
        for end in c["ends"]:
            if end not in tools:
                unregistered[end].append(c)
        if low(c.get("method")) == "manual_csv" and crit:
            flag("WARN", "FLW10", c, "depends on a manual CSV step on a critical connection; record who runs it, when, and what happens when they are away.")
    for end, using in sorted(unregistered.items()):
        for c in using:
            flagged[c["id"]].add("FLW09")
            fails[c["id"]].add("FLW09")
        report.add("FAIL", "FLW09", f"{end} appears in {', '.join(c['id'] for c in using)} and is not in the tool register. Register it before the next step.",
                   f"connections {rows_text([c['_row'] for c in using])}")

    mapped = {end for c in live for end in c["ends"]}
    for tid in sorted(critical - mapped):
        report.add("WARN", "FLW11", f"{name(tid)} is critical and appears in no mapped connection. Map what it sends and receives.", f"tools row {tools[tid]['row']}")

    for c in live:
        if flagged[c["id"]]:
            continue
        method = low(c.get("method")) or "method unknown"
        if c["dated"]:
            report.add("PASS", c["id"], f"{path(c)} ({method}, observed {c['checked']}) has no findings.",
                       f"connections row {c['_row']}", name="no-findings")
        else:
            report.add("NOT CHECKED", c["id"], f"{path(c)} ({method}, {c['status_l']}): the register checks found nothing, but "
                       "no dated evidence shows records arriving. Run the four-case test with existing records.",
                       f"connections row {c['_row']}", name="not-observed")
    report.finish()
    counts = report.counts()
    summary = f"{counts['FAIL']} FAIL, {counts['WARN']} WARN, {counts['PASS']} PASS, {counts['NOT CHECKED']} NOT CHECKED. Nothing was changed."

    # Mermaid stack map
    shown = {end for c in conns for end in c["ends"]}
    groups = defaultdict(list)
    for tid, t in tools.items():
        if t["status"] not in OUT_OF_SCOPE and (t["capabilities"] or tid in shown):
            layer = vocabulary.get(t["capabilities"][0], "other") if t["capabilities"] else "other"
            groups[layer or "other"].append(tid)
    lines = ["flowchart LR"]
    for layer in LAYER_ORDER + sorted(set(groups) - set(LAYER_ORDER)):
        if groups.get(layer):
            lines.append(f'  subgraph layer_{node_id(layer)}["{sanitise(layer)}"]')
            for tid in sorted(groups[layer]):
                owner = tools[tid]["owner"] or "owner unknown"
                lines.append(f'    {node_id(tid)}["{sanitise(tid + " " + tools[tid]["name"])}<br/>{sanitise(owner)}"]')
            lines.append("  end")
    strays = sorted(end for end in shown if end not in tools)
    if strays:
        lines.append('  subgraph layer_unregistered["not in the register"]')
        lines += [f'    {node_id(end)}["{sanitise(end)} not in the register"]' for end in strays]
        lines.append("  end")
    for c in conns:
        if not c.get("source_id") or not c.get("destination_id"):
            continue
        arrow = "-->" if c["dated"] else "-.->"
        parts = [c["id"]] + sorted(fails.get(c["id"], [])) + [low(c.get("method")) or "method unknown"]
        if c.get("via_id"):
            parts.append("via " + name(c["via_id"]))
        if not c["dated"]:
            parts.append("no dated evidence" if c["status_l"] == "observed" else c["status_l"])
        lines.append(f"  {node_id(c['source_id'])} {arrow}|{sanitise(' '.join(parts))}| {node_id(c['destination_id'])}")
    if strays:
        lines.append("  classDef unregistered stroke-dasharray: 5 5")
        lines.append("  class " + ",".join(node_id(end) for end in strays) + " unregistered")
    diagram = "\n".join(lines)

    if args.mermaid_only:
        print(diagram)
    elif args.json:
        print(json.dumps({"as_of": as_of.isoformat(), "inputs": {"tools": len(tools), "connections": len(conns), "checked": len(live)},
                          "notes": [mask(note) for note in notes],
                          "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"],
                          "mermaid": diagram, "summary": {**counts, "line": summary}}, indent=2))
    else:
        out = [f"Martech connection checks as of {as_of} (read-only; reads local files, writes nothing)", "", "Inputs",
               f"  tools: {os.path.basename(args.tools)}, {len(tools)} rows",
               f"  connections: {os.path.basename(args.connections)}, {len(conns)} rows, {len(live)} checked, "
               f"{len(conns) - len(live)} proposed (drawn dotted, not checked)",
               "  Row numbers count the header as row 1."]
        if not vocabulary:
            out.append("  Note: no vocabulary file, so every tool is drawn in the 'other' layer.")
        out += [f"  Note: {mask(note)}" for note in notes]
        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 += ["", "Stack map (solid arrows observed with dated evidence, dotted arrows configured, proposed or observed without it; "
                    "a check ID in a label marks a FAIL)",
                "```mermaid", diagram, "```", "", summary]
        print("\n".join(out))
    return 1 if counts["FAIL"] else 0


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