#!/usr/bin/env python3
"""Score a marketing data audit findings register.

Usage:
  python3 scripts/score_audit.py FINDINGS.csv --config CONFIG.json
      [--as-of YYYY-MM-DD] [--json]

FINDINGS.csv uses the header of assets/findings-register.csv (copy the header
into the run folder; never append to the skill's own asset):
  finding_id, check_id, status, outputs, value, threshold, threshold_source,
  evidence, owner, due, decision_ref, notes
One row per sub-finding. status is PASS, WARN, FAIL, NOT CHECKED or N/A.
outputs holds output IDs separated by semicolons, or "all". Template text in
angle brackets (<role>, <YYYY-MM-DD>) in either file stops the run with
exit status 2 and the line it is on.

CONFIG.json has the shape of assets/audit-config-template.json. The script
reads as_of, outputs (id, name, type, in_use, extra_blocking) and the optional
threshold_overrides and domain_weights blocks.

What it computes:
  - check status: the worst row in the order FAIL, WARN, NOT CHECKED, PASS;
    a check whose rows are all N/A is N/A; a check with no row is NOT CHECKED
  - domain score: 100 x sum(weight x credit) / sum(weight) over rated checks,
    impact weights A 3, B 2, C 1, credits PASS 1, WARN 0.5, FAIL 0, capped
    at 60 when the domain has a class A FAIL; coverage is rated weight over
    applicable weight (N/A removed)
  - overall: weighted mean of the capped domain scores, rounded half up, with
    a coverage label; score and band are withheld under 60% coverage
  - a verdict per output from its blocking checks, and P1 to P4 per FAIL and
    WARN row

It cannot judge evidence: a row says PASS because someone read the evidence.
Standard library only. Exit status: 0 when no output is "Do not use until
fixed", 1 when at least one is, 2 on input errors, including a file that is
not UTF-8 CSV and a row with more cells than the header.
"""
import argparse
import csv
import json
import re
import sys
from collections import OrderedDict
from datetime import date
from decimal import ROUND_HALF_UP, Decimal

# Check catalogue: ID, impact class, title. references/scoring-and-verdicts.md
# quotes these constants; change both together and regenerate the regression.
CHECKS = [
    ("FRS-01", "B", "Source manifest built from the platforms"),
    ("FRS-02", "A", "Source-through age per required slice"),
    ("FRS-03", "A", "Expected-slice coverage over the window"),
    ("FRS-04", "B", "Freshness signal read from data, per slice"),
    ("FRS-05", "A", "Reload window covers source restatement"),
    ("FRS-06", "B", "Export and connector health"),
    ("FRS-07", "C", "Empty loads told apart from missing loads"),
    ("KEY-01", "A", "Unique at the declared grain"),
    ("KEY-02", "A", "Required keys present and valid"),
    ("KEY-03", "B", "Join coverage without silent drops"),
    ("KEY-04", "A", "No fan-out after joins"),
    ("KEY-05", "B", "Namespaces and environments separated"),
    ("KEY-06", "A", "Deleted and historical versions handled"),
    ("KEY-07", "B", "Time zone and date basis consistent"),
    ("KEY-08", "A", "Currency and units correct"),
    ("IDN-01", "B", "Click IDs captured into the CRM"),
    ("IDN-02", "B", "Analytics-to-CRM join coverage"),
    ("IDN-03", "A", "CRM-to-billing crosswalk coverage"),
    ("IDN-04", "A", "No conflicting current mappings"),
    ("IDN-05", "A", "No over-linking through shared identifiers"),
    ("IDN-06", "B", "Identifier normalisation safe"),
    ("MET-01", "B", "Contract and owner per critical metric"),
    ("MET-02", "A", "One implementation per metric"),
    ("MET-03", "A", "Aggregation rules respected"),
    ("MET-04", "A", "Attributed totals kept apart from business totals"),
    ("MET-05", "B", "Currency conversion policy and rate table sound"),
    ("MET-06", "C", "Definition changes versioned"),
    ("MET-07", "A", "Platform conversion and spend fields mapped correctly"),
    ("REC-01", "B", "Comparison contract per pair"),
    ("REC-02", "A", "Ad spend agrees between platform and warehouse"),
    ("REC-03", "A", "Ad conversions agree between platform and warehouse copy"),
    ("REC-04", "A", "Analytics outcomes agree with backend or billing"),
    ("REC-05", "B", "Lead submissions reach the CRM"),
    ("REC-06", "A", "CRM and billing bridge explained"),
    ("REC-07", "B", "Platform numbers bridged to business totals"),
    ("REC-08", "B", "Consumers show the warehouse result"),
    ("CHN-01", "B", "Source and medium vocabulary conformance"),
    ("CHN-02", "B", "Unassigned and unknown share"),
    ("CHN-03", "B", "Paid clicks classified as paid"),
    ("CHN-04", "C", "Case and format variants"),
    ("CHN-05", "B", "No unresolved macros or placeholders"),
    ("CHN-06", "A", "Spend joins to traffic"),
    ("CHN-07", "B", "Warehouse channel logic matches the documented rules"),
    ("CHN-08", "B", "CRM source fields usable, unknown kept unknown"),
    ("OPS-01", "B", "Tests on critical models"),
    ("OPS-02", "B", "Test results acted on"),
    ("OPS-03", "B", "Check schedule matches the SLA"),
    ("OPS-04", "A", "Publication gate before syncs and uploads"),
    ("OPS-05", "B", "Schema changes and new values handled"),
    ("OPS-06", "B", "Owners, backups and alert routing"),
    ("OPS-07", "C", "API version and deprecation calendar"),
    ("OPS-08", "A", "Restricted data kept out of shared marts and failure tables"),
]

DOMAINS = OrderedDict([
    ("FRS", ("Freshness and completeness", 20)),
    ("KEY", ("Grain, keys and values", 15)),
    ("IDN", ("Identity", 10)),
    ("MET", ("Metric definitions and owners", 15)),
    ("REC", ("Reconciliation", 20)),
    ("CHN", ("UTM and channel classification", 10)),
    ("OPS", ("Controls and ownership", 10)),
])

IMPACT_WEIGHTS = {"A": 3, "B": 2, "C": 1}
CREDIT = {"PASS": Decimal("1"), "WARN": Decimal("0.5"), "FAIL": Decimal("0")}
CLASS_A_CAP = Decimal("60")
STATUSES = ["PASS", "WARN", "FAIL", "NOT CHECKED", "N/A"]
# Worst first. N/A is handled separately.
SEVERITY = {"FAIL": 0, "WARN": 1, "NOT CHECKED": 2, "PASS": 3}

BLOCKING = {
    "paid_media_performance": [
        "FRS-02", "FRS-03", "FRS-05", "KEY-01", "KEY-08", "MET-01", "MET-07",
        "REC-02", "REC-03", "REC-04", "CHN-06", "CHN-07"],
    "pipeline_and_lead_source": [
        "FRS-02", "FRS-03", "KEY-01", "KEY-03", "KEY-04", "KEY-06", "IDN-05",
        "MET-01", "MET-02", "REC-05", "CHN-08"],
    "revenue_attribution": [
        "FRS-02", "KEY-01", "KEY-04", "KEY-05", "KEY-08", "IDN-03", "IDN-04",
        "MET-01", "MET-05", "REC-06", "CHN-07", "CHN-08"],
    "activation_audience_or_upload": [
        "FRS-02", "FRS-04", "KEY-01", "KEY-02", "IDN-01", "IDN-04", "IDN-05",
        "REC-08", "OPS-04", "OPS-08"],
    "executive_kpi": [
        "KEY-08", "MET-01", "MET-02", "MET-03", "MET-04", "REC-07", "REC-08"],
}

BANDS = [(85, "Healthy"), (70, "Needs work"), (50, "At risk"), (0, "Failing")]
REQUIRED_COLUMNS = [
    "finding_id", "check_id", "status", "outputs", "value", "threshold",
    "threshold_source", "evidence", "owner", "due", "decision_ref", "notes"]

CATALOGUE = OrderedDict((cid, {"impact": imp, "title": title, "domain": cid[:3]})
                        for cid, imp, title in CHECKS)
VERDICT_FAIL = "Do not use until fixed"
# Template text such as <role> or <YYYY-MM-DD run date>. A "<" followed by a digit
# ("<0.5%") is a comparison, not a placeholder.
PLACEHOLDER = re.compile(r"<[A-Za-z][^<>\n]*>")
PLACEHOLDER_LIMIT = 5


class InputError(Exception):
    pass


def placeholder_values(value, path=""):
    """(path, text, match) for every string value with template text; keys starting with _ are notes."""
    hits = []
    if isinstance(value, dict):
        for key, item in value.items():
            if not str(key).startswith("_"):
                hits += placeholder_values(item, "%s.%s" % (path, key) if path else str(key))
    elif isinstance(value, list):
        for i, item in enumerate(value):
            hits += placeholder_values(item, "%s[%d]" % (path, i))
    elif isinstance(value, str):
        m = PLACEHOLDER.search(value)
        if m:
            hits.append((path, value, m.group(0)))
    return hits


def placeholder_errors(found):
    errors = found[:PLACEHOLDER_LIMIT]
    if len(found) > PLACEHOLDER_LIMIT:
        errors.append("and %d more placeholder values" % (len(found) - PLACEHOLDER_LIMIT))
    return errors


def q(value, places):
    """Round half up to the given number of decimal places."""
    exp = Decimal(1).scaleb(-places) if places else Decimal(1)
    return value.quantize(exp, rounding=ROUND_HALF_UP)


def natural_key(text):
    return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", text)]


def worst(statuses):
    rated = [s for s in statuses if s != "N/A"]
    if not rated:
        return "N/A" if statuses else None
    return min(rated, key=lambda s: SEVERITY[s])


def load_config(path):
    try:
        with open(path, encoding="utf-8-sig") as handle:
            raw = handle.read()
        config = json.loads(raw)
    except (OSError, ValueError) as exc:
        raise InputError("cannot read config %s: %s" % (path, exc))
    if not isinstance(config, dict):
        raise InputError("config %s is not a JSON object; copy assets/audit-config-template.json" % path)
    found, lines, start = [], raw.splitlines(), 0
    for where, text, match in placeholder_values(config):
        needle = json.dumps(text)[1:-1]
        line_no = next((i + 1 for i in range(start, len(lines))
                        if needle in lines[i] and not lines[i].lstrip().startswith('"_')), None)
        if line_no:
            start = line_no - 1
            found.append("config: placeholder text on line %d (%s: %s)" % (line_no, where, match))
        else:
            found.append("config: placeholder text at %s (%s)" % (where, match))
    if found:
        raise InputError("; ".join(placeholder_errors(found)) +
                         "; replace every <...> value before scoring")
    errors = []
    outputs = config.get("outputs")
    if not isinstance(outputs, list) or not 1 <= len(outputs) <= 5:
        errors.append("config must list one to five outputs")
        outputs = outputs if isinstance(outputs, list) else []
    if not all(isinstance(out, dict) for out in outputs):
        raise InputError("every entry in outputs must be a JSON object")
    seen = set()
    for out in outputs:
        oid = str(out.get("id", "")).strip()
        if not oid or oid.lower() == "all":
            errors.append("every output needs an id other than 'all'")
            continue
        if oid in seen:
            errors.append("output id %s is duplicated" % oid)
        seen.add(oid)
        if out.get("type") not in BLOCKING:
            errors.append("output %s has type %r; use one of %s" % (
                oid, out.get("type"), ", ".join(BLOCKING)))
        for extra in out.get("extra_blocking") or []:
            cid = extra.get("check_id") if isinstance(extra, dict) else None
            if cid not in CATALOGUE:
                errors.append("output %s extra_blocking has unknown check %r" % (oid, cid))
            elif not str(extra.get("reason", "")).strip():
                errors.append("output %s extra_blocking %s needs a reason" % (oid, cid))
    weights = config.get("domain_weights")
    if weights:
        if not str(config.get("domain_weights_reason", "")).strip():
            errors.append("domain_weights needs domain_weights_reason")
        for key, value in weights.items():
            if key not in DOMAINS:
                errors.append("domain_weights has unknown domain %s" % key)
            elif not isinstance(value, (int, float)) or value < 0:
                errors.append("domain weight for %s must be a number of 0 or more" % key)
    for item in config.get("threshold_overrides") or []:
        if item.get("check_id") not in CATALOGUE:
            errors.append("threshold_overrides has unknown check %r" % item.get("check_id"))
        elif not str(item.get("reason", "")).strip():
            errors.append("threshold override for %s needs a reason" % item.get("check_id"))
    if errors:
        raise InputError("; ".join(errors))
    return config


def load_findings(path, output_ids):
    rows = []
    try:
        with open(path, newline="", encoding="utf-8-sig") as handle:
            reader = csv.DictReader(handle)
            header = [h.strip() for h in (reader.fieldnames or [])]
            for row in reader:
                if None in row:
                    raise InputError("findings line %d has more cells than the header; put double quotes around "
                                     "any value that holds a comma" % reader.line_num)
                rows.append({(k or "").strip(): (v or "").strip() for k, v in row.items()})
    except OSError as exc:
        raise InputError("cannot read findings %s: %s" % (path, exc))
    except (UnicodeDecodeError, csv.Error):
        raise InputError("findings %s is not a UTF-8 CSV file; save a workbook as CSV UTF-8" % path)
    missing = [c for c in REQUIRED_COLUMNS if c not in header]
    if missing:
        raise InputError("findings file lacks columns: %s" % ", ".join(missing))
    found = []
    for number, row in enumerate(rows, start=2):
        for column in REQUIRED_COLUMNS:
            m = PLACEHOLDER.search(row.get(column, ""))
            if m:
                found.append("findings: placeholder text on line %d (%s: %s)" % (number, column, m.group(0)))
    if found:
        raise InputError("; ".join(placeholder_errors(found)) +
                         "; copy the header of assets/findings-register.csv and write real values")
    errors = []
    seen = set()
    for number, row in enumerate(rows, start=2):
        where = "line %d" % number
        fid = row["finding_id"]
        if not fid:
            errors.append("%s: empty finding_id" % where)
        elif fid in seen:
            errors.append("%s: duplicate finding_id %s" % (where, fid))
        seen.add(fid)
        if row["check_id"] not in CATALOGUE:
            errors.append("%s: unknown check_id %r" % (where, row["check_id"]))
        status = row["status"].upper()
        if status not in STATUSES:
            errors.append("%s: status %r is not one of %s" % (where, row["status"], ", ".join(STATUSES)))
        row["status"] = status
        if status in ("NOT CHECKED", "N/A") and not row["notes"]:
            errors.append("%s: %s row needs a reason in notes" % (where, status))
        targets = [t.strip() for t in row["outputs"].split(";") if t.strip()]
        if not targets:
            errors.append("%s: outputs is empty; use output IDs or 'all'" % where)
        elif [t for t in targets if t.lower() == "all"]:
            if len(targets) > 1:
                errors.append("%s: use 'all' alone, not with output IDs" % where)
            row["targets"] = list(output_ids)
            row["is_all"] = True
        else:
            unknown = [t for t in targets if t not in output_ids]
            if unknown:
                errors.append("%s: unknown output IDs %s" % (where, ", ".join(unknown)))
            row["targets"] = targets
            row["is_all"] = False
    if errors:
        raise InputError("; ".join(errors))
    return rows


def blocking_for(output):
    checks = list(BLOCKING[output["type"]])
    for extra in output.get("extra_blocking") or []:
        if extra["check_id"] not in checks:
            checks.append(extra["check_id"])
    return checks


def status_for_output(rows_by_check, cid, oid):
    """Worst status of a check for one output, or None when excluded (N/A)."""
    rows = [r for r in rows_by_check.get(cid, []) if oid in r["targets"]]
    if not rows:
        return "MISSING"
    if any(r["status"] == "N/A" and not r["is_all"] for r in rows):
        return None
    rated = [r["status"] for r in rows if r["status"] != "N/A"]
    if not rated:
        return None
    return worst(rated)


def score(rows, config):
    warnings = []
    outputs = config["outputs"]
    output_ids = [str(o["id"]).strip() for o in outputs]
    rows_by_check = OrderedDict((cid, []) for cid in CATALOGUE)
    for row in rows:
        rows_by_check[row["check_id"]].append(row)

    check_status = OrderedDict()
    for cid, crow in rows_by_check.items():
        if not crow:
            check_status[cid] = "NOT CHECKED"
            warnings.append("%s has no finding row; treated as NOT CHECKED (no finding recorded)" % cid)
        else:
            check_status[cid] = worst([r["status"] for r in crow])

    domain_weights = dict((d, w) for d, (_, w) in DOMAINS.items())
    if config.get("domain_weights"):
        domain_weights.update(config["domain_weights"])
        warnings.append("domain weights overridden (%s): %s" % (
            config.get("domain_weights_reason"),
            ", ".join("%s %s" % (d, domain_weights[d]) for d in DOMAINS)))

    domains = OrderedDict()
    total_rated = Decimal(0)
    total_applicable = Decimal(0)
    for dom, (name, _) in DOMAINS.items():
        rated_w = earned = applicable_w = Decimal(0)
        class_a_fail = False
        for cid, meta in CATALOGUE.items():
            if meta["domain"] != dom:
                continue
            st = check_status[cid]
            weight = Decimal(IMPACT_WEIGHTS[meta["impact"]])
            if st == "N/A":
                continue
            applicable_w += weight
            if st in CREDIT:
                rated_w += weight
                earned += weight * CREDIT[st]
                if st == "FAIL" and meta["impact"] == "A":
                    class_a_fail = True
        raw = (Decimal(100) * earned / rated_w) if rated_w else None
        capped = min(raw, CLASS_A_CAP) if (raw is not None and class_a_fail) else raw
        coverage = (Decimal(100) * rated_w / applicable_w) if applicable_w else None
        total_rated += rated_w
        total_applicable += applicable_w
        domains[dom] = {
            "name": name, "weight": domain_weights[dom], "rated_weight": rated_w,
            "applicable_weight": applicable_w, "earned": earned, "raw": raw,
            "score": capped, "capped": bool(class_a_fail and raw is not None and raw > CLASS_A_CAP),
            "coverage": coverage}

    num = den = Decimal(0)
    for info in domains.values():
        if info["score"] is not None and info["weight"]:
            num += Decimal(str(info["weight"])) * info["score"]
            den += Decimal(str(info["weight"]))
    overall = (num / den) if den else None
    coverage = (Decimal(100) * total_rated / total_applicable) if total_applicable else Decimal(0)
    if coverage >= 80:
        label = "graded"
    elif coverage >= 60:
        label = "provisional"
    else:
        label = "insufficient evidence"
    rounded = int(q(overall, 0)) if overall is not None else None
    band = None
    if rounded is not None:
        band = next(name for floor, name in BANDS if rounded >= floor)

    # Verdicts per output.
    verdicts = []
    blocking_map = {}
    for out in outputs:
        oid = str(out["id"]).strip()
        blocking = blocking_for(out)
        blocking_map[oid] = blocking
        fails, missing, caveats = [], [], []
        for cid in blocking:
            st = status_for_output(rows_by_check, cid, oid)
            if st is None:
                continue
            if st == "FAIL":
                fails.append(cid)
            elif st in ("NOT CHECKED", "MISSING"):
                missing.append(cid)
            elif st == "WARN":
                caveats.append(cid)
        if fails:
            verdict = VERDICT_FAIL
        elif missing:
            verdict = "Not assessable"
        elif caveats:
            verdict = "Use with caveats"
        else:
            verdict = "Decision-grade"
        other_fail, other_warn = [], []
        for cid in CATALOGUE:
            if cid in blocking:
                continue
            st = status_for_output(rows_by_check, cid, oid)
            if st == "FAIL":
                other_fail.append(cid)
            elif st == "WARN":
                other_warn.append(cid)
        verdicts.append(OrderedDict([
            ("id", oid), ("name", out.get("name", "")), ("type", out["type"]),
            ("in_use", out.get("in_use", True) is not False), ("verdict", verdict),
            ("blocking_fail", fails), ("not_assessed", missing), ("caveats", caveats),
            ("other_fail", other_fail), ("other_warn", other_warn)]))

    # Priorities and row warnings.
    in_use = dict((str(o["id"]).strip(), o.get("in_use", True) is not False) for o in outputs)
    priorities = []
    downgraded = []
    for row in rows:
        st = row["status"]
        if st not in ("FAIL", "WARN"):
            continue
        on_blocking = [oid for oid in row["targets"] if row["check_id"] in blocking_map[oid]]
        if st == "FAIL":
            prio = "P1" if any(in_use[oid] for oid in on_blocking) else "P2"
            if row["decision_ref"]:
                warnings.append("%s is FAIL and cites decision %s; set WARN if the decision accepts it" % (
                    row["finding_id"], row["decision_ref"]))
        else:
            prio = "P3" if on_blocking else "P4"
            if row["decision_ref"]:
                downgraded.append("%s %s: %s" % (row["finding_id"], row["check_id"], row["decision_ref"]))
            if on_blocking and (not row["owner"] or not row["due"]):
                warnings.append("%s WARN on blocking check %s (%s) lacks %s" % (
                    row["finding_id"], row["check_id"], ";".join(on_blocking),
                    "an owner and a due date" if not row["owner"] and not row["due"]
                    else ("an owner" if not row["owner"] else "a due date")))
        priorities.append(OrderedDict([
            ("priority", prio), ("finding_id", row["finding_id"]), ("check_id", row["check_id"]),
            ("status", st), ("outputs", "all" if row["is_all"] else ";".join(row["targets"])),
            ("value", row["value"]), ("owner", row["owner"]), ("due", row["due"])]))
    priorities.sort(key=lambda p: (p["priority"], natural_key(p["finding_id"])))

    not_checked, not_applicable = [], []
    for cid, st in check_status.items():
        crow = rows_by_check[cid]
        if st == "NOT CHECKED":
            reasons = [r["notes"] for r in crow if r["status"] == "NOT CHECKED"] or ["no finding recorded"]
            not_checked.append("%s %s: %s" % (cid, CATALOGUE[cid]["title"], "; ".join(reasons)))
        elif st == "N/A":
            not_applicable.append("%s %s: %s" % (cid, CATALOGUE[cid]["title"],
                                                 "; ".join(r["notes"] for r in crow)))
    for row in rows:
        if row["status"] == "N/A" and not row["is_all"] and check_status[row["check_id"]] != "N/A":
            not_applicable.append("%s for %s: %s" % (row["check_id"], ";".join(row["targets"]), row["notes"]))

    counts = OrderedDict((s, 0) for s in STATUSES)
    for st in check_status.values():
        counts[st] += 1
    prio_counts = OrderedDict((p, 0) for p in ("P1", "P2", "P3", "P4"))
    for p in priorities:
        prio_counts[p["priority"]] += 1

    return OrderedDict([
        ("rows", len(rows)), ("counts", counts), ("check_status", check_status),
        ("domains", domains), ("overall", overall), ("overall_rounded", rounded),
        ("band", band), ("coverage", coverage), ("coverage_label", label),
        ("verdicts", verdicts), ("priorities", priorities), ("priority_counts", prio_counts),
        ("not_checked", not_checked), ("not_applicable", not_applicable),
        ("downgraded", downgraded), ("warnings", warnings),
        ("threshold_overrides", config.get("threshold_overrides") or [])])


def fmt_pct(value):
    return "%s%%" % q(value, 1)


def verdict_line(v):
    text = "%s %s (%s): %s." % (v["id"], v["name"], v["type"], v["verdict"])
    if v["blocking_fail"]:
        text += " Blocking FAIL: %s." % ", ".join(v["blocking_fail"])
        if v["not_assessed"]:
            text += " Also not assessed: %s." % ", ".join(v["not_assessed"])
    elif v["not_assessed"]:
        text += " Not assessed: %s." % ", ".join(v["not_assessed"])
    if v["caveats"]:
        text += " Caveats: %s." % ", ".join(v["caveats"])
    return text


def overall_line(result):
    cov = fmt_pct(result["coverage"])
    if result["overall"] is None:
        return "Overall score not available: no rated checks."
    if result["coverage_label"] == "insufficient evidence":
        return ("Overall score and band withheld: coverage %s, insufficient evidence. "
                "Read the findings by domain." % cov)
    return "Overall %d (%s), %s, coverage %s, %s." % (
        result["overall_rounded"], q(result["overall"], 2), result["band"], cov,
        result["coverage_label"])


def domain_line(dom, info):
    if info["score"] is None:
        cov = fmt_pct(info["coverage"]) if info["coverage"] is not None else "n/a"
        return "%s %s: not rated, coverage %s" % (dom, info["name"], cov)
    text = "%s %s: %s" % (dom, info["name"], q(info["score"], 1))
    if info["capped"]:
        text += " (capped from %s)" % q(info["raw"], 1)
    return text + ", coverage %s" % fmt_pct(info["coverage"])


def print_markdown(result, as_of):
    c = result["counts"]
    out = ["# Marketing data audit score", ""]
    out.append("As of %s. %d checks from %d findings: %d PASS, %d WARN, %d FAIL, %d N/A, %d NOT CHECKED." % (
        as_of, len(result["check_status"]), result["rows"], c["PASS"], c["WARN"], c["FAIL"],
        c["N/A"], c["NOT CHECKED"]))
    out += ["", "## Verdicts per output", ""]
    for v in result["verdicts"]:
        out.append("- " + verdict_line(v))
        extra = []
        if v["other_fail"]:
            extra.append("FAIL " + ", ".join(v["other_fail"]))
        if v["other_warn"]:
            extra.append("WARN " + ", ".join(v["other_warn"]))
        if extra:
            out.append("  - Other findings tagged to %s (no effect on the verdict): %s." % (
                v["id"], "; ".join(extra)))
    out += ["", "## Score", "", overall_line(result),
            "The score ranks where to look; the verdicts above decide what can be used.", ""]
    for dom, info in result["domains"].items():
        out.append("- " + domain_line(dom, info))
    pc = result["priority_counts"]
    out += ["", "## Priorities", "",
            "Priorities: P1 %d, P2 %d, P3 %d, P4 %d." % (pc["P1"], pc["P2"], pc["P3"], pc["P4"]), ""]
    for p in result["priorities"]:
        out.append("- %s %s %s %s (%s): %s" % (p["priority"], p["finding_id"], p["check_id"],
                                              p["status"], p["outputs"], p["value"]))
    out += ["", "## Not checked", ""]
    out += ["- " + x for x in result["not_checked"]] or ["- none"]
    out += ["", "## Not applicable", ""]
    out += ["- " + x for x in result["not_applicable"]] or ["- none"]
    if result["downgraded"]:
        out += ["", "## Downgraded with a decision", ""]
        out += ["- " + x for x in result["downgraded"]]
    if result["threshold_overrides"]:
        out += ["", "## Threshold overrides", ""]
        for item in result["threshold_overrides"]:
            out.append("- %s: %s (%s)" % (item["check_id"], item.get("threshold", ""), item["reason"]))
    out += ["", "## Warnings", ""]
    out += ["- " + w for w in result["warnings"]] or ["- none"]
    out += ["", "Nothing was changed."]
    print("\n".join(out))


def to_jsonable(value):
    if isinstance(value, Decimal):
        return float(value)
    if isinstance(value, dict):
        return OrderedDict((k, to_jsonable(v)) for k, v in value.items())
    if isinstance(value, list):
        return [to_jsonable(v) for v in value]
    return value


def main(argv=None):
    parser = argparse.ArgumentParser(description="Score a marketing data audit findings register.")
    parser.add_argument("findings", help="findings register CSV")
    parser.add_argument("--config", required=True, help="audit config JSON")
    parser.add_argument("--as-of", help="date printed on the report (default: config as_of, else today)")
    parser.add_argument("--json", action="store_true", help="print JSON instead of Markdown")
    args = parser.parse_args(argv)
    try:
        try:
            config = load_config(args.config)
        except (AttributeError, TypeError):
            raise InputError("config %s does not have the shape of assets/audit-config-template.json" % args.config)
        output_ids = [str(o["id"]).strip() for o in config["outputs"]]
        rows = load_findings(args.findings, output_ids)
        as_of = args.as_of or config.get("as_of") or date.today().isoformat()
        if not re.match(r"^\d{4}-\d{2}-\d{2}$", str(as_of)):
            raise InputError("as-of must be YYYY-MM-DD")
        result = score(rows, config)
    except InputError as exc:
        print("error: %s" % exc, file=sys.stderr)
        return 2
    if args.json:
        data = to_jsonable(result)
        data["as_of"] = as_of
        data["overall_line"] = overall_line(result)
        print(json.dumps(data, indent=2))
    else:
        print_markdown(result, as_of)
    return 1 if any(v["verdict"] == VERDICT_FAIL for v in result["verdicts"]) else 0


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